Hi Three.js team,
I’m new to the forum and I don’t know how to create a PR on GitHub, sorry.
I’d like to propose a feature to improve vertex transformations for InstancedMesh and BatchMesh in TSL / WebGPU.
Context
Currently in Three.js:
-
positionLocalis transformed byinstanceMatrixinsideInstanceNode. -
Hooks like
vertexNodeandpositionNoderun before the instance matrix. -
This makes it impossible to apply transformations that depend on vertex positions (wind, displacement, animations, global effects) in world space consistently across all instances.
-
Shadows and lights work correctly only in local / geometry space, not in world space.
Semantic Issue
Currently, in InstanceNode we have:
const instancePosition = instanceMatrixNode.mul(positionLocal).xyz
positionLocal.assign(instancePosition)
This is semantically confusing because positionLocal now actually contains the world-space position of the vertex, yet it is named positionLocal. Conceptually, This could instead be attributed to positionWorld or an intermediate position. This naming mismatch can mislead developers when writing vertex hooks or post-instance transformations.
Proposal
-
Expose the
instanceMatrixNodein both InstancedMesh and BatchMesh. -
Provide post-instanceMatrix hooks in the material, e.g.:
THREE.InstanceNode.prototype.setup=function(builder){
const {instanceMatrix,instanceColor}=this
let {instanceMatrixNode,instanceColorNode}=this
const {count}=instanceMatrix,
material=builder.object.material // < added
if(instanceMatrixNode===null){
if(count<=1000){instanceMatrixNode=buffer(instanceMatrix.array,'mat4',Math.max(count,1)).element(instanceIndex)}
else{
const buffer=new THREE.InstancedInterleavedBuffer(instanceMatrix.array,16,1)
this.buffer=buffer
const bufferFn=instanceMatrix.usage===THREE.DynamicDrawUsage?instancedDynamicBufferAttribute:instancedBufferAttribute,
instanceBuffers=[bufferFn(buffer,'vec4',16,0),bufferFn(buffer,'vec4',16,4),bufferFn(buffer,'vec4',16,8),bufferFn(buffer,'vec4',16,12)]
instanceMatrixNode=mat4(...instanceBuffers)}
this.instanceMatrixNode=instanceMatrixNode
}
builder.instanceMatrixNode=instanceMatrixNode // < added
if(instanceColor&&instanceColorNode===null){
const buffer=new THREE.InstancedBufferAttribute(instanceColor.array,3),
bufferFn=instanceColor.usage===THREE.DynamicDrawUsage?instancedDynamicBufferAttribute:instancedBufferAttribute
this.bufferColor=buffer
instanceColorNode=vec3(bufferFn(buffer,'vec3',3,0))
this.instanceColorNode=instanceColorNode
}
const instancePosition=instanceMatrixNode.mul(positionLocal).xyz
positionLocal.assign(instancePosition)
// Post-instanceMatrix hooks
if(material.afterInstancePositionNode){positionLocal.assign(material.afterInstancePositionNode)}
if(builder.hasGeometryAttribute('normal')){
const instanceNormal=transformNormal(normalLocal,instanceMatrixNode)
normalLocal.assign(instanceNormal)
if(material.afterInstanceNormalNode){normalLocal.assign(material.afterInstanceNormalNode)}
}
if(this.instanceColorNode!==null){varyingProperty('vec3','vInstanceColor').assign(this.instanceColorNode)}
}
THREE.BatchNode.prototype.setup=function(builder){
const material=builder.object.material // < added
if(this.batchingIdNode===null){
if(builder.getDrawIndex()===null){this.batchingIdNode=instanceIndex}
else{this.batchingIdNode=drawIndex}}
const getIndirectIndex=Fn(([id])=>{
const size=int(textureSize(textureLoad(this.batchMesh._indirectTexture),0).x),
x=int(id).mod(size),
y=int(id).div(size)
return textureLoad(this.batchMesh._indirectTexture,ivec2(x,y)).x
}).setLayout({name:'getIndirectIndex',type:'uint',inputs:[{name:'id',type:'int'}]})
const indirectId=getIndirectIndex(int(this.batchingIdNode)),
matricesTexture=this.batchMesh._matricesTexture,
size=int(textureSize(textureLoad(matricesTexture),0).x),
j=float(indirectId).mul(4).toInt().toVar(),
x=j.mod(size),
y=j.div(size),
batchingMatrix=mat4(
textureLoad(matricesTexture,ivec2(x,y)),
textureLoad(matricesTexture,ivec2(x.add(1),y)),
textureLoad(matricesTexture,ivec2(x.add(2),y)),
textureLoad(matricesTexture,ivec2(x.add(3),y))
),
colorsTexture=this.batchMesh._colorsTexture
this.instanceMatrixNode=batchingMatrix // added
builder.instanceMatrixNode=batchingMatrix // added
if(colorsTexture!==null){
const getBatchingColor=Fn(([id])=>{
const size=int(textureSize(textureLoad(colorsTexture),0).x),
j=id,
x=j.mod(size),
y=j.div(size)
return textureLoad(colorsTexture,ivec2(x,y)).rgb
}).setLayout({name:'getBatchingColor',type:'vec3',inputs:[{ name:'id',type:'int'}]})
const color=getBatchingColor(indirectId)
varyingProperty('vec3','vBatchColor').assign(color)
}
const bm=mat3(batchingMatrix)
positionLocal.assign(batchingMatrix.mul(positionLocal))
if(material.afterInstancePositionNode){positionLocal.assign(material.afterInstancePositionNode)}
const transformedNormal=normalLocal.div(vec3(bm[0].dot(bm[0]),bm[1].dot(bm[1]),bm[2].dot(bm[2]))),
batchingNormal=bm.mul(transformedNormal).xyz
normalLocal.assign(batchingNormal)
if(material.afterInstanceNormalNode){normalLocal.assign(material.afterInstanceNormalNode)}
if(builder.hasGeometryAttribute('tangent')){
tangentLocal.mulAssign(bm)
if(material.afterInstanceTangentLocalNode){tangentLocal.assign(material.afterInstanceTangentLocalNode)}
}
}
- Provide a helper to retrieve the instance matrix node in both cases:
const getInstanceMatrix=/*#__PURE__*/Fn((builder)=>{return builder.instanceMatrixNode})()
- Examples of use:
myMaterial.afterInstancePositionNode=/*#__PURE__*/Fn(()=>{
const instancedPosition=positionLocal.toVar()
instancedPosition.addAssign(someCustomOffsetPositionNode)
return instancedPosition
})()
myMaterial.vertexNode=/*#__PURE__*/Fn(()=>{
positionLocal.assign(getInstanceMatrix.mul(positionLocal).xyz)
positionWorld.assign(modelWorldMatrix.mul(positionLocal).xyz)
// or positionWorld.assign(positionLocal) if modelWorldMatrix is by default
positionView.assign(modelViewMatrix.mul(positionWorld).xyz)
modelViewProjection.assign(cameraProjectionMatrix.mul(positionView))
return modelViewProjection
})()
// be careful, vertexNode is actually not compatible with shadows pipeline
- Example modifications for InstanceNode and BatchNode are include
Testing
-
I tested this in a complex animated scene with multiple lights, instanced and batched meshes (no skinning or morph targets yet).
-
Shadows and lights remain coherent.
-
Global post-instanceMatrix vertex transformations (wind, displacement, etc.) are now possible in world space.
-
The node getInstanceMatrix works equally well with InstancedMesh and BatchedMesh, without changing material,therefore, we can batch several instances of instancedMesh with the same material.
I use it extensively in my project in an automated way. This allowed me to batch dozens of different trees with thousands of instances, and with consistent wind in the foliage regardless of their rotation, position, and scale. It works well with lights and shadows.
Suggestion
Consider adding official afterInstancePositionNode / afterInstanceNormalNode / afterInstanceTangentLocalNode hooks, and clarify the distinction between positionLocal and positionWorld for instance transformations, to allow safe and consistent post-instanceMatrix operations.
I understand that positionWorld is only truly global after being multiplied by modelWorldMatrix, but in the case of instances and batching, this multiplication is very rarely used. Therefore, we have a possible intermediate node `instancedPosition` after positionLocal and before positionWorld.