-
-
Notifications
You must be signed in to change notification settings - Fork 404
Description
Based on a lively discussion on Twitter with @pzuraq et al, I am trying to summarize our (that's @nickschot and me) experience and the shortcomings of using Ember to build a declarative layer for DOM-less rendering.
In our specific case rendering a 3D scene, using an additional layer of abstraction by using an Entity-Component-System to improve composability, to large parts inspired by the popular A-Frame framework.
Tell me more...
Rendering a template essentially creates a side-effect, by calling (imperative) APIs that you would
normally not use by yourself. The "render target" is predominantly DOM, and the APIs are DOM-APIs like document.createElement() or el.appendChild(). And as such DOM as the rendering target is built into GlimmerVM itself (unlike AFAIK react which needs react-dom for DOM-bindings, but also allows for completely DOM-less environments like react-native).
In the following I want to bring up shortcomings of our current Ember/Glimmer APIs when trying to
render to a different render target (a 3D scene, managed by some 3D library like three.js
or babylon.js in our case, but that could be also something like a leaflet map etc.)
Managing hierarchies
Just as DOM represents a hierarchy of elements, a 3D scene is also organized in a hierarchy of nodes. For example you could parent a light node to a mesh node, so when you move the mesh (say a car) the light would follow.
In DOM-land this is easy, as Glimmer will manage the hierarchy automatically:
<Sidebar>
<Button>
</Sidebar>By nesting the button component inside of the sidebar component, the rendering side-effect of the button component (a <button> DOM element) will automatically be added as a child to the sidebar (basically using sidebarEl.appendChild(buttonEl)).
It would be essential that this is just as easy in a no-DOM world, like:
<Scene>
<Box>
<Light>
</Box>
</Scene>Unfortunately it is not. Here the Light component, which would create a light instance using the 3D library's API like new PointLight(), would need to e.g. set its parent to the instance created by the Box component, to match the hierarchy in the template. But as we now have to manage the rendering in a 3D context by ourselves, instead of relying on GlimmerVM's built-in DOM-rendering support, we need to know the light's parent (component).
Legacy Ember.Component classes do have a parentView property with which this would be possible, but Glimmer components do not have this, and it's currently not possible to provide that functionality using a custom component manager (which we already use).
We could use contextual components or other yielded context to pass the hierarchy along:
<Scene as |scene|>
<Box @parent={{scene}} as |box|>
<Light @parent={{box}}/>
</Box>
</Scene>or
<Scene as |s|>
<s.box as |b|>
<b.light/>
</s.box>
</Scene>But I see a number of serious problems with that:
I would call this "implementation-driven design" rather than "design-driven implementation": it
considerably falls short of the easy of use and readability of DOM-based rendering, or other declarative systems like A-Frame or react-three-fiber which do not have to make these kind of DX sacrifices.
The second example in particular has another big drawback, in that it requires every 3D component to yield any other component, although they don't really have to know of each other. This introduces serious difficulties to enable extensibility, like having components from a library, which now would also have to yield user-provided custom components, just for the purpose of managing the hierarchy.
And it provides a severe possibility to shoot yourself in the foot, as the visible hierarchy - provided by the nesting and indentation of component, as we know it from DOM - is not really relevant for the actual hierarchy of the rendering side-effect. Can you spot the problem here:
<Scene as |s|>
<s.box as |b|>
<s.light/>
</s.box>
</Scene>The light will actually not be a child of the box! 🤯
tl;dr
We need a way for a custom component manager to supply its managed components their parent component, not for our regular DOM-based components (where this is managed automatically for us in GlimmerVM), but for other render targets were we have to manage the hierarchy of the rendering side-effect by ourselves.
DOM-less modifiers
Modifiers provide an extraordinarily elegant way to compose functionality, splitting previously big fat monolithic classes to small, reusable functionality that do just one thing, but do it well (aka the "Unix philosophy").
But they have one caveat: the API is inherently coupled to DOM. While there are use cases for things that very much match modifiers in a DOM-less world.
Let's take the most common modifier as an example: {{on}} to handle events. It takes the rendering side-effect as its "context", which in DOM-world is a DOM node, and attaches a listener.
In a 3D scene, we have just the same use case: we want something to happen when the user clicks on a 3D object. So continuing with our primitive example from above, something like this:
<Scene>
<Box {{three-on "click" this.doSomething}}>
<Light>
</Box>
</Scene>Here the rendering side-effect would not be a DOM node, but a node in our 3D scene (e.g. a mesh), that needs to be passed as the context of the modifier to do its event handling work (which is very different btw to DOM events, as it requires raycasting calculations in 3D space to find the affected 3D object "under the mouse")
From a user's point of view, the above example of a 3D-world modifier would IMO pretty much match with how we think of modifiers in a DOM-world. But again, this is not possible as the modifier manager only knows of DOM elements as the modifiers "context".
You could certainly implement event handling functionality inside of the component, like <Box @onClick={{this.doSomething}}>. But that feels like a big step backwards, similar to how Ember.Component classes had all these event handler methods, instead of the simpler composability over inheritance that we got with modifiers.
This falls apart even more if you want to add special behavior that is not possible to bake into every component that might need this, like some physics behaviour (using e.g. cannon.js):
<Scene {{physics gravity=(vector3 0 -9.81 0)}}>
<Box {{physics mass=1 restitution=0.9}}>
<Light>
</Box>
</Scene>Outlook
Currently we have found a way to somehow make our 3D library work with user-facing APIs similar to the examples above, i.e. managing hierarchy implicitly and enabling DOM-less modifiers. However these are just "dirty workarounds" by doing custom AST transforms, which are quite ugly and probably error prone. But at least it enabled us to design our APIs the way we wanted them to be, so prioritizing ease of use over ease of implementation.
But it showed that though Ember is in principle able to support DOM-less rendering, there are still considerable shortcomings when digging deeper into the space and focusing on DX-friendly APIs.
So I hope this helps to drive the conversation, to make Ember even more awesome, by fully embracing DOM-less rendering!
Note: our current work is publicly available as ember-ecsy-babylon and ecsy-babylon, however it's still very much WIP and not ready for general use