Created
June 6, 2026 06:25
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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