Skip to content

Instantly share code, notes, and snippets.

@promontis
Created June 6, 2026 06:25
Show Gist options
  • Select an option

  • Save promontis/92b317a34ad5075c907db887df0fc2de to your computer and use it in GitHub Desktop.

Select an option

Save promontis/92b317a34ad5075c907db887df0fc2de to your computer and use it in GitHub Desktop.
three.js r184 WebGPURenderer — InstancedMesh receiveShadow renders black when shadow camera uses a non-default layer (button A/Bs the fix)
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>three.js r184 WebGPU — InstancedMesh shadow-receive bug</title>
<style>
html,body{margin:0;height:100%;background:#202428;overflow:hidden;font:13px/1.5 ui-monospace,monospace;color:#dde6ff}
#hud{position:fixed;top:10px;left:10px;z-index:10;background:rgba(0,0,0,.6);padding:10px 12px;border-radius:6px;white-space:pre;pointer-events:none}
#hud b{color:#e8c46d}
.warn{color:#ff8f6d}.ok{color:#8fe88f}
button{pointer-events:auto;margin-top:8px;font:inherit;cursor:pointer}
</style>
</head>
<body>
<div id="hud"></div>
<!-- Direct CDN import (no importmap) so it runs anywhere: gistpreview, jsfiddle, a local file. -->
<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.webgpu.js';
/* ------------------------------------------------------------------ *
* three.js r184 WebGPURenderer bug:
*
* An InstancedMesh that RECEIVES shadows is rendered fully (wrongly)
* in shadow when the DirectionalLight casts via the "proxy on a
* dedicated layer" pattern, i.e. the shadow camera is restricted to
* a non-default layer: light.shadow.camera.layers.set(SHADOW_LAYER).
*
* - The green InstancedMesh (receiver) goes BLACK — even the faces
* pointing at the light.
* - A magenta plain Mesh receiver in the same frame stays correct.
* - The same InstancedMesh is correct under ordinary self-cast
* shadows (button toggles the two paths).
* ------------------------------------------------------------------ */
const SHADOW_LAYER = 2;
const N = 4;
const PILLAR = new THREE.BoxGeometry(1, 3, 1);
const X = i => (i - (N - 1) / 2) * 2.4;
const CX = X(N - 1) + 3;
const renderer = new THREE.WebGPURenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x202428);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 200);
camera.position.set(7, 4.2, 12);
camera.lookAt(0, 1.5, 0);
scene.add(new THREE.AmbientLight(0xffffff, 0.15));
const key = new THREE.DirectionalLight(0xfff0e0, 3.2);
key.position.set(5, 7, 4);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
Object.assign(key.shadow.camera, { left: -12, right: 12, top: 12, bottom: -12, near: 0.5, far: 50 });
scene.add(key, key.target);
// Ground — plain Mesh receiver (control).
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(80, 80),
new THREE.MeshStandardNodeMaterial({ color: 0x3a4450, roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// VICTIM — InstancedMesh receiver (green).
const recA = new THREE.InstancedMesh(
PILLAR, new THREE.MeshStandardNodeMaterial({ color: 0x55cc55, roughness: 0.6 }), N);
recA.receiveShadow = true;
recA.frustumCulled = false;
for (let i = 0; i < N; i++) recA.setMatrixAt(i, new THREE.Matrix4().makeTranslation(X(i), 1.5, 0));
recA.instanceMatrix.needsUpdate = true;
scene.add(recA);
// CONTROL — plain Mesh receiver (magenta), same shape, beside the instanced ones.
const recC = new THREE.Mesh(
PILLAR, new THREE.MeshStandardNodeMaterial({ color: 0xcc55cc, roughness: 0.6 }));
recC.position.set(CX, 1.5, 0);
recC.receiveShadow = true;
scene.add(recC);
// Caster proxies for the PROXY/layer path (invisible to the main camera).
const proxyA = new THREE.InstancedMesh(PILLAR, new THREE.MeshStandardNodeMaterial(), N);
proxyA.frustumCulled = false;
proxyA.layers.set(SHADOW_LAYER);
for (let i = 0; i < N; i++) proxyA.setMatrixAt(i, new THREE.Matrix4().makeTranslation(X(i), 1.5, 0));
proxyA.instanceMatrix.needsUpdate = true;
const proxyC = new THREE.Mesh(PILLAR, new THREE.MeshStandardNodeMaterial());
proxyC.position.set(CX, 1.5, 0);
proxyC.layers.set(SHADOW_LAYER);
let proxyMode = true; // start in the buggy configuration
function applyShadowPath() {
if (proxyMode) {
recA.castShadow = false; recC.castShadow = false;
proxyA.castShadow = true; proxyC.castShadow = true;
if (!proxyA.parent) scene.add(proxyA, proxyC);
key.shadow.camera.layers.set(SHADOW_LAYER); // <-- the trigger
} else {
recA.castShadow = true; recC.castShadow = true;
if (proxyA.parent) scene.remove(proxyA, proxyC);
key.shadow.camera.layers.set(0); // ordinary path
}
key.shadow.needsUpdate = true;
hud();
}
const el = document.getElementById('hud');
function hud() {
el.innerHTML =
`<b>three.js ${THREE.REVISION}</b> · WebGPURenderer\n` +
`InstancedMesh shadow-receive bug\n\n` +
`shadow path: ${proxyMode
? '<span class="warn">PROXY/layer — light.shadow.camera.layers.set(2)\n → green InstancedMesh receiver goes BLACK (BUG)</span>'
: '<span class="ok">STANDARD self-cast — shadow camera on layer 0\n → all receivers correct</span>'}\n\n` +
`green pillars = InstancedMesh receiver (victim)\n` +
`magenta pillar = plain Mesh receiver (stays correct)\n` +
`<button id="t">Toggle shadow path</button>`;
document.getElementById('t').onclick = () => { proxyMode = !proxyMode; applyShadowPath(); };
}
await renderer.init();
applyShadowPath();
renderer.setAnimationLoop(() => renderer.render(scene, camera));
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment