Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 503 conditional rendering of primitives #514

Merged
merged 10 commits into from
Feb 21, 2024
Prev Previous commit
Next Next commit
chore: refactor instance swaping logic to overwrite set and copy prop…
…erties
  • Loading branch information
alvarosabu committed Jan 19, 2024
commit 2df1e651f2311c9fb24f53ccc1b1e257a97a7cd2
3 changes: 2 additions & 1 deletion playground/src/pages/primitives.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ const sphere = new Mesh(
}),
)

Copy link
Contributor

@andretchen0 andretchen0 Jan 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In nodeOps.ts, I mentioned that cloning means that setup references are lost. Below is some demonstration code that can be pasted here.

useRenderLoop().onLoop(() => {
  torus.rotateX(0.01)
  torusKnot.rotateY(0.01)
})

Both torus and torusKnot continue to exist after the clone, but they no longer point to the on-screen objects, so the on-screen objects don't update.

If the objects aren't clone()d in nodeOps, the on-screen shapes will rotate – but other errors pop up.

I don't know if there's a workaround here. Just pointing it out.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andretchen0 This argument makes me think we should abandon the idea of trying to make :object prop reactive, swiping instances without losing the ref is getting too complicated.

Users could still use primitives with conditional rendering, I wanted primitive to work similar to component but honestly at this point I don't know how to achieve it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alvarosabu

I don't understand the internals of Tres well enough to have an opinion on the feasibility here.

I'll start reading the source after I finish <AnimatedSprite /> for Cientos.

I wanted primitive to work similar to component but honestly at this point I don't know how to achieve it.

I agree that this is a worthwhile goal. It'd be great if it "just works".

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andretchen0 let me know if you are up to a pair-programming call to discuss the internals when you get free let me know, we definitely can use your knowledge and your quality feedback inside of the custom renderer code. 💚

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alvarosabu

Sure thing. That could be fun!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @andretchen0 I created a thread on the core team discord channel discussing this but to give a summary:

I found a way of making it work (animations), the only constraint is that the object passed through :object needs to be of the same type (mesh -> mesh) (group -> group). If we do (mesh -> group), it doesn't work anymore.

Screen_Recording_2024-02-02_at_12.43.44.mov

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I'll go read the Discord.

sphere.position.set(2, -2, 0)

const firstGroup = new Group()
firstGroup.add(torus)
firstGroup.add(torusKnot)
Expand All @@ -86,7 +88,6 @@ secondGroup.add(sphere)
<OrbitControls />
<primitive
v-if="isVisible"
:position="[4, 2, 0]"
:object="knot ? firstGroup : sphere"
/>
<Suspense>
Expand Down
21 changes: 12 additions & 9 deletions src/core/nodeOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,25 +191,28 @@ export const nodeOps: RendererOptions<TresObject, TresObject> = {
if (node) {
let root = node
let key = prop
if ( key === 'object' && prevValue !== null) {
const newInstance = nodeOps.createElement('primitive', false, null, {
if (key === 'object' && prevValue !== null) {
// If the prop 'object' is changed, we need to re-instance the object and swap the old one with the new one
const newInstance = nodeOps.createElement('primitive', undefined, undefined, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like this doesn't break references in <script> per se. The :objects still exist. But they're copied. That means something like the following (probably) wouldn't have the user's desired effect in the playground example:

useRenderLoop().onLoop(({elapsed}) => {
  torus.position.y = Math.sin(elapsed)
})

Whenever the primitive object becomes torus, torus is copied, along with its attributes. But the copy doesn't move like torus moves, because it's a different Torus.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andretchen0 we will need to explicitly mention on the docs that the user needs to animate the ref (primitiveRef) but not the object (torus) following the same DX as they were using a <TorusMesh ref="torusRef" />

Screen.Recording.2024-02-08.at.10.14.03.mov

There are several reasons why we need to clone the object:

  • Since we soft-swipe the instances when :object changes (we maintain the reference but copy all the attributes) that would modify the original object from the setup leading to unwanted side effects.
    It is not possible to pass a ref or reactive as @JaimeTorrealba mentioned in the thread. With clone, we can ensure that swiping between models is possible.

This is what happens without the clone

Screen.Recording.2024-02-08.at.10.24.31.mov

It's either has this limitation of having to use the ref coming from the custom renderer to animate or :object can't be reactive and users will need to use v-if and v-else to swipe models:

<primitive v-if="showA" :object="modelA" />
<primitive v-else :object="modelB" />

I balance through the first option.

Copy link
Contributor

@andretchen0 andretchen0 Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alvarosabu

I think the PR as it currently stands is an improvement. I don't want to stand in the way of improvements.

I approved the PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sort of error is an itch I can't stop scratching, so if you want to keep scratching ...

Since we soft-swipe the instances when :object changes (we maintain the reference but copy all the attributes) that would modify the original object from the setup leading to unwanted side effects.

Can you elaborate on unwanted side effects?

Technical issues aside, there's a point that isn't clear for me conceptually. To illustrate:

<script>
...
const userMesh = new Mesh(...)
userMesh.position.x = 100

const userMesh2 = new Mesh(...)
userMesh2.position.x = 200
</script>

<template>
...
<primitive ref="primitiveRef" :object="userMesh" :position-x="300" />
...
</template>
  • Should userMesh's x be 300? (300 is the value in the primitive)
  • And later, if the user does primitiveRef.value.object = userMesh2, does that set userMesh2's x to 300?

For R3F, the answer to both is "yes", it seems. Here's a StackBlitz. Notice that the rotation of the TorusRing increments with every frame, but resets whenever it's made the primitive.object. And the Torus would normally be placed at x = 2000, but its x is set to the primitive's value.

That seems like the right solution to me.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const newInstance = nodeOps.createElement('primitive', undefined, undefined, {

I understand that this will make a clone, which is required at the moment, but is there a reason why it's necessary to go through nodeOps.createElement? Is it updating something on the Vue side, maybe?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate on unwanted side effects?

One side-effect is that is not possible to pass a ref or reactive as @JaimeTorrealba mentioned in the thread on Discord. With clone, we can ensure that swiping between models is possible.

Should userMesh's x be 300? (300 is the value in the primitive)

Yes, the prop has priority because it will overwrite the mesh positions on the setup.

And later, if the user does primitiveRef.value.object = userMesh2, does that set userMesh2's x to 300?

Users shouldn't overwrite vue template refs, wouldn't it be kind of an antipattern? I also see the users complaining about having to do this extra step of primitiveRef.value.object = userMesh2 vs :object being reactive.

const newInstance = nodeOps.createElement('primitive', undefined, undefined, {

That was to not having to duplicate the same code of the createElement inside of the pathProp since primitives have some special treatment, like localstate flags and such.

object: nextValue,
})
const blacklistedKeys = ['uuid', 'position', 'rotation', 'scale', 'quaternion']
newInstance.uuid = node.uuid
for (const key in newInstance) {
if (!blacklistedKeys.includes(key)) {
node[key] = newInstance[key]
}
for (const subkey in newInstance) {
const target = node[subkey]
const value = newInstance[subkey]
if (!target?.set && !isFunction(target)) node[subkey] = value
else if (target.constructor === value.constructor && target?.copy) target?.copy(value)
else if (Array.isArray(value)) target.set(...value)
else if (!target.isColor && target.setScalar) target.setScalar(value)
else target.set(value)
}
// This code is needed to handle the case where the prop 'object' type change from a group to a mesh or vice versa, otherwise the object will not be rendered correctly (models will be invisible)
if (newInstance.isGroup) {
node.geometry = undefined
node.material = undefined
}
else {
delete node.isGroup
}

}

if (node.isObject3D && key === 'blocks-pointer-events') {
Expand Down