DEV Community

Cover image for Loosely coupled code: Babylon vs Three.js
Joe Pea
Joe Pea

Posted on

Loosely coupled code: Babylon vs Three.js

Hello! :)

A while back, I started a port of Three.js from JavaScript to AssemblyScript (an awesome TypeScript to WebAssembly compiler):

GitHub logo lume / glas

WebGL in WebAssembly with AssemblyScript

LUME

GLAS

WebGL in WebAssembly with AssemblyScript.

This is a work-in-progress port of Three.js, a JavaScript 3D WebGL library, into AssemblyScript.

Motivation

It'd be sweet to have a high-performing WebGL engine that runs in the web via WebAssembly and is written in a language that web developers are already familiar with JavaScript in the form of TypeScript (a superset of JavaScript with types).

Enter AssemblyScript, a toolchain that allows us to write a strictly-typed subset of TypeScript code and compile it to WebAssembly (an assembly-like language representing machine code) for speed.

Status

⚠️ ALPHA STATE

The project is currently in its very early alpha stages. We have an amazing group of programmers building the initial ASWebGLue library. This library allows AssemblyScript programs to call the browser's underlying WebGL interface. This is required before we are able render anything to the screen. A…

I was originally interested in porting Babylon because it is already written in TypeScript, so porting would be easier compared to taking a JavaScript codebase and adding types to it after refactoring the dynamic parts that TypeScript can't handle.

However, when I started to port Babylon classes, I soon realized that the classes in the library are highly coupled: importing Babylon’s Scene class means practically importing the whole Babylon library and instantiating many parts of the library (new This, new That, etc) even if those parts will never be used by your application.

Essentially, with Babylon you want the banana (f.e. Scene) and you get the Gorilla and the whole jungle (rest of the Babylon lib).

High coupling is something to be avoided when possible..

To get an understanding of this problem with respect to Babylon's codebase, take look at the import statements in Babylon’s scene.ts:

import { Nullable } from "./types";
import { Tools } from "./Misc/tools";
import { IAnimatable } from './Animations/animatable.interface';
import { PrecisionDate } from "./Misc/precisionDate";
import { Observable, Observer } from "./Misc/observable";
import { SmartArrayNoDuplicate, SmartArray, ISmartArrayLike } from "./Misc/smartArray";
import { StringDictionary } from "./Misc/stringDictionary";
import { Tags } from "./Misc/tags";
import { Vector2, Vector3, Matrix, TmpVectors, Vector4 } from "./Maths/math.vector";
import { Geometry } from "./Meshes/geometry";
import { TransformNode } from "./Meshes/transformNode";
import { SubMesh } from "./Meshes/subMesh";
import { AbstractMesh } from "./Meshes/abstractMesh";
import { Mesh } from "./Meshes/mesh";
import { IParticleSystem } from "./Particles/IParticleSystem";
import { Bone } from "./Bones/bone";
import { Skeleton } from "./Bones/skeleton";
import { MorphTargetManager } from "./Morph/morphTargetManager";
import { Camera } from "./Cameras/camera";
import { AbstractScene } from "./abstractScene";
import { BaseTexture } from "./Materials/Textures/baseTexture";
import { Texture } from "./Materials/Textures/texture";
import { RenderTargetTexture } from "./Materials/Textures/renderTargetTexture";
import { ImageProcessingConfiguration } from "./Materials/imageProcessingConfiguration";
import { Effect } from "./Materials/effect";
import { UniformBuffer } from "./Materials/uniformBuffer";
import { MultiMaterial } from "./Materials/multiMaterial";
import { Light } from "./Lights/light";
import { PickingInfo } from "./Collisions/pickingInfo";
import { ICollisionCoordinator } from "./Collisions/collisionCoordinator";
import { PointerEventTypes, PointerInfoPre, PointerInfo } from "./Events/pointerEvents";
import { KeyboardInfoPre, KeyboardInfo } from "./Events/keyboardEvents";
import { ActionEvent } from "./Actions/actionEvent";
import { PostProcessManager } from "./PostProcesses/postProcessManager";
import { IOfflineProvider } from "./Offline/IOfflineProvider";
import { RenderingGroupInfo, RenderingManager, IRenderingManagerAutoClearSetup } from "./Rendering/renderingManager";
import { ISceneComponent, ISceneSerializableComponent, Stage, SimpleStageAction, RenderTargetsStageAction, RenderTargetStageAction, MeshStageAction, EvaluateSubMeshStageAction, PreActiveMeshStageAction, CameraStageAction, RenderingGroupStageAction, RenderingMeshStageAction, PointerMoveStageAction, PointerUpDownStageAction, CameraStageFrameBufferAction } from "./sceneComponent";
import { Engine } from "./Engines/engine";
import { Node } from "./node";
import { MorphTarget } from "./Morph/morphTarget";
import { Constants } from "./Engines/constants";
import { DomManagement } from "./Misc/domManagement";
import { Logger } from "./Misc/logger";
import { EngineStore } from "./Engines/engineStore";
import { AbstractActionManager } from './Actions/abstractActionManager';
import { _DevTools } from './Misc/devTools';
import { WebRequest } from './Misc/webRequest';
import { InputManager } from './Inputs/scene.inputManager';
import { PerfCounter } from './Misc/perfCounter';
import { IFileRequest } from './Misc/fileRequest';
import { Color4, Color3 } from './Maths/math.color';
import { Plane } from './Maths/math.plane';
import { Frustum } from './Maths/math.frustum';
import { UniqueIdGenerator } from './Misc/uniqueIdGenerator';
import { FileTools, LoadFileError, RequestFileError, ReadFileError } from './Misc/fileTools';
import { IClipPlanesHolder } from './Misc/interfaces/iClipPlanesHolder';
import { IPointerEvent } from "./Events/deviceInputEvents";
import { WebVRFreeCamera } from "./Cameras/VR/webVRCamera";
Enter fullscreen mode Exit fullscreen mode

That's a fairly long list for what a "Scene" represents, and I can see the whole list on my computer screen without scrolling.

In contrast, the Three.js codebase is much more loosely coupled, which to me is very attractive from a code authoring perspective, so I ended up choosing Three.js and accepting that I would perform more porting from JavaScript instead of from TypeScript, because I felt the end result would be cleaner.

As an example of Three’s lose coupling, here are the first few lines of Three’s Scene.js:

import { Object3D } from '../core/Object3D.js';

class Scene extends Object3D {

    constructor() {

        super();

        this.type = 'Scene';

        this.background = null;
        this.environment = null;
        this.fog = null;
Enter fullscreen mode Exit fullscreen mode

Explore Three's code base, and you’ll see that classes try to be minimal and do only one thing well (or representing only one concept well).

After starting the port using Babylon, going back to Three.js felt pleasant due to the loose coupling. The Three.js codebase is clean.

If I had to choose to develop with, or extend from, one codebase or the other, I would choose Three.js because the loosely-coupled organization within the library makes code maintenance and extension easier, while allowing certain parts of the Three library to be used without pulling unneeded dependencies into an application.


I wanted to ask the Babylon community if anyone else there feels that Babylon's parts are too tightly coupled compared to, for example, Three.js, but my post was immediately hidden as "spam":

https://forum.babylonjs.com/t/high-coupling-in-the-bablyon-codebase-compared-to-three-js/21156/3

(You might not be able to see it if they permanently delete it).

In that thread, I asked

Has anyone else (especially on Bablyon’s team) thought about this difference between Babylon and Three.js? What are your thoughts on this? Are there any plans or desires to decouple the Babylon code base?

I was hoping to open a discussion on the topic, hoping it might light a fire for Babylon improvement, for everyone's benefit (having alternatives is always great).

Discussion (6)

Collapse
seanmay profile image
Sean May • Edited on

I don't want to push on this too hard, but that isn't necessarily an example of a "loose" coupling. It's an example of implicit (literally hidden) coupling. It is implicit coupling, as by looking at this file, you have no idea what Object3D requires in order to do its job, or even what it does.

Object3D imports EventDispatcher and 7 other things, and then inherits from EventDispatcher (so Scene is an Object3D is an EventDispatcher... ).
"You wanted a Scene, but instead, you got an Object3D holding a scene, and the whole EventDispatcher" - Joe Armstrong [poorly paraphrased]
Armstrong (creator of Erlang) was literally talking about C++ / Java-esque inheritance when he said that.

Object3D imports 7 other things, and then news up a bunch of vectors and quaternions.
One of the imports is Euler math, which itself has 4 imports (most/all of which are shared in one form or another, but they still need to be resolved).

I'm not from the game / low-level software world, but generally, in higher-level systems, "loose" coupling would mean that I could have a Scene that was completely independent of any particular implementation of Object3D (up to and including even just having the import statement in the Scene's declaration file; because then you are still tied to that particular implementation, and if Object3D does anything wild, during its declaration, then you are going to trigger that behaviour, by importing it, if it hasn't been loaded already; and Object3D does make changes in its declaration file, that run as soon as it's imported. It sets a bunch of variables before it defines the class. Thankfully, they're all module-scoped and not global variables, but that's just due to the benevolence of the developer). If Scene was loosely coupled, and needed an Object3D, or needed an EventDispatcher, it would be given one at runtime (or at library bootstrap time).
Tight-coupling generally leads to god/blob objects in OO, or leads to having nasty crashes if you import files in the wrong order (in something imperative, like PHP scripting).
Loose coupling leads to lots of functions / components / classes, with minimal state mutation outside of themselves... including this where other ancestor classes/mixins can affect/be affected by that state (because then those objects wouldn't be portable/swappable). The downside being that you then have lots of Lego that you need to put back together, separately from where the pieces are defined.

I'm not saying that Babylon is more or less tightly coupled (again, the ability to remove one single file and replace its implementation at runtime, with nothing going wrong, would be a good example of loose coupling), but it is an example of explicit coupling, versus implicit coupling.
Three.js struck me as library code (using it inside of your system), while Babylon tutorials made it feel like more of a framework (writing your code inside of their system). I'm sure they've both changed over the years, but even the library/framework dichotomy is separate from component cohesion/coupling.

And if I needed to rewrite a system from scratch, I would hope that all of the components were loosely coupled, with no mixins/inheritance and instead having bootstraps / factories / etc to manage the pieces at runtime...
if that was out of the question, then I would prefer the whole thing be explicitly coupled, so that I didn't have to chase import statements several files upstream, to find bugs / unexpected behaviour; I would know what I needed to deal with, just by looking at it, head-on.

Collapse
trusktr profile image
Joe Pea Author • Edited on

Seems you may be mixing code organization with coupling. Class inheritance is nothing to be afraid of, and used properly it is good for code re-use. Nothing is 100% decoupled, but Three.js is definitely less coupled.

Collapse
seanmay profile image
Sean May • Edited on

I don't think that it is a conflation, at all.
I wouldn't think a Scene, in a general sense, is a renderable object, by name alone. I would think it contains / organizes renderable objects, but I would not expect the thing itself to be drawn and visible, innately, based upon its name. No more than I would expect a Stage to be shown, versus hosting the actors and sets, which are. The stage is necessary, but is a part of a play’s meta-structure, and should be virtually invisible to an engaged audience (and thus I wouldn't be rendering it for them to see).

Class inheritance is error-prone (logical errors) and the majority of researched authors/scholars warn to prefer other methods, lest your app end up with a gigantic god-blob object, or upon adding a new feature, you need to throw out your whole codebase, and redo your class taxonomy, due to the diamond problem, coupled with some unhealthy DRY obsession.

Quick example:

  • a Duck is a Bird
  • a Penguin is a bird

what should they inherit from so that

  • they operate in a single-inheritance system
  • they reuse all code so no method is written twice (penguins and ducks swim; ducks fly)
  • Bird operates as expected (don't have to catch bird.fly, because why even have a Bird in that case)
  • all subtypes of Bird adhere to Barbara Liskov’s subtyping assertion (that all subtypes, to be treated as valid subtypes, must be wholly substitutable for the supertype, such that the user is blind as to which it is given)

Now add an Ostrich.

With that understanding (and if you have a different understanding, that's fine... but this is the context I am working with, and I think it's pretty clear that I understand the taxonomic constraints of working with classes and deep hierarchy), I would expect a Scene to be able to handle different forms of renderable objects, so long as they conform to a known, common interface (again Liskov’s subtyping assertion... or Martin’s “Liskov Substitution Principle”). As it stands, you can not mix different objects, from different libraries, et cetera, because they rely on instance checks (because inheritance), and the only way to pass instance checks is to generate one of the same instance (or, in JS, to manually overwrite the constructor and proto chain) thus locking you into using only actual Object3D objects, and not other conformant objects.

This means that those objects and the scene are indeed tightly coupled. If I wanted a different type of external object to be renderable, well, I would literally need a completely different Scene, which, on its face, doesn't make any sense... and would mean that I need a completely different renderer, because I changed my scene, because I wanted to draw this one other object that I loaded using some other library.
Again, that sounds like my play needing a different stage, if I want to import a set from somewhere else; even if I am willing and able to modify the set to fit my current stage.

That is a prime example of tight coupling.

And yes, this also affects code organization, but as you can hopefully see from my example, this isn't a mutually exclusive statement, and my argument wasn't remotely about organization, as code-interop (and the blast-radius of making a piece of code interoperable) has very little to do with file structure.

Thread Thread
neovance profile image
Devon Bagley

Scene is just the root of a traversable tree of child objects, and may or may not be renderable, but have the transform, position, rotation, etc context of it's parent. The renderer calls scene.traverse, and scene.traverseVisible respectively, with a callback function to build the render buffer each frame.

The problem with inheritance that you are supplying is flawed by design. Bird is a classification that is not based on purpose, or function to begin with. Bird is a classification of animal which share certain traits, but those traits aren't related to the ability to fly or swim. This particular problem can easily be solved using well known design patterns within which inheritance can play a role. Direct inheritance is not the only tool applied to all problems.

There is no instance check. Three.js merely looks for a truthy property on the object called isObject3D, and isScene, etc for each "type" and assumes that the object implements the interface, so it should be possible to use objects from other libraries using a simple adapter to implement the interface.

In any case, looking at the files being imported doesn't really tell you the whole story of what is tightly coupled or not. Babylon is typescript, and thus all of the types used must be imported for static analysis of the type hints.

Thread Thread
seanmay profile image
Sean May

A Scene also needs a "translateOnAxis" and a "setRotationFromQuaternion", et al, because it is an Object3D. It doesn't just possess the information, but rather also possesses all of the methods, because of inheritance.
It also has Observer / Pub-Sub features, because Scene is also an EventDispatcher, because Object3D is an EventDispatcher.

This isn't tightly-coupled to you? How is the cohesion of all of the features of Scene, for you? Does the abstract concept of a "scene", or a scene graph, or a render batch list, or whatever abstraction you're going to use, always come with event pub-sub, and its own built in quaternion methods?
I would say that is low cohesion. If you got a list of all properties and methods on Scene, or on the superclass of Scene or on the supersuperclass of Scene... I believe that they would not all make sense to be on one object, to solve one problem.

"it should be possible"... until somewhere down the stack, if it assumes that it has a Scene, that it can add an event listener, or fire an event on it... and yes, if I were to write a new Scene from scratch, then it needs to contain everything that a regular Scene would inherit down the line, if I expect it to conform.

"In any case, looking at the files being imported doesn't really tell you the whole story of what is tightly coupled or not."
It really does. In every case, if a JS file imports another JS file, then the importer is tightly coupled to the imported...
If A imports B, then A gives B the chance to run code and change global state, just by virtue of being called. That's tightly coupled. Moreover, assuming that you have C that you want to use instead of B... if A has imported B directly, then chances are that the methods of A are going to use B directly, rather than using the C you want it to use, because it has reference to B in its code...
That is prima facie tight-coupling with A having a hard dependency on B (such that C can't be used as substitute, without also rewriting A).

"Babylon is typescript, and thus all of the types used must be imported for static analysis of the type hints."
And? Types can live in their own .ts files, or be imported separately using the import type { } statement, or the new import { type X } expression. None of that involves importing any running code. Moreover, all TypeScript types are structural types, not nominal types. That means that any and all types/interfaces in TypeScript are happily polymorphic and swappable for any interface that matches. Thus, you don't even need the class or a base class for polymorphism in TS. You don't need classes to all declare that they implement an interface. They don't even need to be class instances at all.
Loose coupling would be if Scene wasn't an Object3D, didn't have a bunch of math methods, didn't have pub-sub... didn't load/initialize a bunch of math libs, et cetera, if you load the file directly (by having it load Object, and having that load the rest)...
...and rather, I could use my own implementation of objects or scenes, separate from the whole ThreeJS or Babylon framework that they're residing in.

Collapse
steveblue profile image
Steve Belovarich • Edited on

It’s a shame open source maintainers cannot engage with honest criticism. Toxic positivity is a thing in web development. Thank you Joe for all the contributions you’ve made over the years to open source.

EDIT: I see someone replied after your comments were flagged as spam. Hopefully we will get the sort of engagement that benefits the community.