Journal
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
Progress
Section titled “Progress”- three 0.186.0,
@types/three0.186.0, Vite 8.3.0, TypeScript 7.0.2, Playwright 1.63.0 and glTF Transform 4.5.0 pinned in the course’s ownpackage.jsonand lock file -
check.sh: type-checking, the Node.js scripts, every lesson page probed in headless Chromium, the production build, compared withexpected/ - CI on Ubuntu, Windows and macOS
- Lesson 1: scene, camera, renderer
- Lesson 2: geometries, materials, lights and shadows
- Lesson 3: color, tone mapping and HDR environments
- Lesson 4: glTF models and animations
- Lesson 5: interaction,
Raycasterand camera controls - Lesson 6: TSL and node materials
- Lesson 7: post-processing with
RenderPipeline - Lesson 8: performance, measured
- Lesson 9: React Three Fiber and drei
- Lesson 10: physics with Rapier in WebAssembly
- Lesson 11: WebXR, emulated
- Lesson 12: tests and CI
- Lesson 13: project, GuitarAlchemist’s 3D guitar neck
- Lesson 14: lab, Guitar Alchemist in 3D, fifteen measured experiments
- Course complete
- Live demos: every lesson page and five complete scenes, published under the site with a Try it panel
three.js r186 and the libraries around it are somebody else’s software; this table holds what the lessons found in them, not in the pages the course writes. Most of it comes from the move to WebGPURenderer, which is new enough that a WebGL habit is often the thing that is wrong. Nothing here has been reported upstream except where the Status column names an existing issue.
| Expected | What happens | Where | Measure | Status |
|---|---|---|---|---|
WebGPURenderer reports the draw calls the scene has, like WebGLRenderer |
With tone mapping, or an output color space that differs from the working space, it renders into a target and draws that in an output pass | three.js r186 WebGPURenderer |
2 draw calls and 13 triangles for one cube, against 1 on WebGL; the two info objects also have different shapes |
Reproduced 2026-09-16 |
@types/three describes toJSON() as the runtime writes it |
In 0.186.0 Object3DJSON declares children as strings and has neither geometries nor materials; the runtime writes objects and both arrays |
@types/three 0.186.0 |
The compiler errors are kept in errors/l01_tojson_types.ts |
Reproduced. No upstream issue looked for, none opened 2026-09-16 |
PCFSoftShadowMap works on WebGPURenderer |
Removed in r186, and it warns. R3F sets it for any truthy shadows and gets the same warning once per render of <Canvas> |
three.js r186, @react-three/fiber 9.7.0 | GA’s LunarLanderEngine.ts and MinimalThreeInstrument.tsx still pair it with WebGPURenderer |
Reproduced 2026-09-16 · 2026-09-16 · Not yet in GA, which pins three 0.180.0, where it still works; 2026-09-24 |
| A point light and a spot light convert lumens to candela the same way | SpotLight.power uses π instead of 4π and does not change with the cone’s angle |
three.js SpotLight.power |
800 lm gives 63.66 cd on a point light and 254.65 on a spot | Reproduced 2026-09-16 |
| Building an equirectangular environment costs about one pass | The PMREM costs 17 renders: 1 into the first level, then 2 for each of the 8 others, even for a plain equirectangular texture | three.js PMREMGenerator |
17 renders for a 256 by 128 environment | Reproduced, by design 2026-09-16 |
| The bundler ships the Draco decoders the browser loads | Since r185 DRACOLoader resolves them with import.meta.url, so Vite copies all five |
three.js r185+ DRACOLoader, Vite |
1.31 MB bundled where a browser loads two files; the build also warns that three.webgpu is over 500 kB |
Reproduced, changed behaviour since r184 2026-09-16 |
| Compression keeps every vertex the geometry has | Draco and meshopt both drop the two pole vertices SphereGeometry creates and no triangle uses; Draco’s weld() also merges the non-indexed body’s corners |
Draco, meshopt, glTF Transform 4.5.0 | Vertex counts before and after, in expected/ |
Reproduced, defensible 2026-09-16 |
drei’s <Text> renders on WebGPURenderer as it does on WebGL |
It draws a blank quad: troika-three-text injects its shader through onBeforeCompile, which the node renderer never calls |
drei 10.7.8 | Blank quad | Reproduced. Still open: whether a WebGPU-ready troika-three-text exists 2026-09-16 |
setColorAt colours instances whatever the first render did |
An InstancedMesh ignores it if its first render had no instance colors |
three.js InstancedMesh |
The port’s markers stayed white until the attribute was allocated up front | Reproduced, worked around 2026-09-16 |
WebXR on WebGPURenderer behaves as on WebGLRenderer |
It needs XRGPUBinding and the webgpu feature; its error message names a VRButtonGPU that does not exist; WebGLXRFallback swaps renderers but the controllers taken from the first one stop receiving events |
three.js r186 WebXR on WebGPU | Only the classic WebGLRenderer rendered both eyes under IWER |
Reproduced 2026-09-16 |
InstancedMesh works with morph targets |
It fails with one instance, morphTargetInfluences never created, and differently with several, in UniformArrayNode.updateBuffer. It also needs geometry.morphTargetsRelative = true, silently |
three.js r186 | Two distinct failures, one per instance count | Reproduced 2026-09-17 |
BatchedMesh batches into few draw calls on every backend |
On WebGPU it draws one call per object | three.js r186 BatchedMesh, WebGPU backend |
44 draw calls for one neck against 3 on WebGL 2, which uses WEBGL_multi_draw |
Reproduced 2026-09-17 |
renderer.info.memory.texturesSize counts compressed textures |
It counts 0 bytes for KTX2 textures, and counts render targets instead | three.js r186 renderer.info.memory |
0 bytes for the compressed wood set, which is why experiment 6 could not be finished | Reproduced. Still open: measuring KTX2 GPU memory with a GPU tool 2026-09-17 |
powerPreference picks a GPU |
Chromium on Windows ignores it and warns; the other OSes do not | Chromium 153 | check.sh filters the line |
Known upstream, Chromium issue 369219127 2026-09-16 |
three.js’s RapierPhysics addon follows a current Rapier |
r186’s addon still loads 0.17.3 from skypack.dev, while the course uses 0.20.0 | three.js r186 examples/jsm/physics/RapierPhysics |
Two minor versions apart | Reproduced, the course does not use the addon 2026-09-16 |
| R3F’s WebGPU build ships the WebGPU entry point alone | It imports three next to three/webgpu, so the WebGL core is bundled as well |
@react-three/fiber 9.7.0 | 565 kB gzip against 210 kB for the same scene without React | Reproduced. Still open: aliasing three to three/webgpu 2026-09-17 |
Experiments
Section titled “Experiments”Lesson 14 ran fifteen experiments in the Guitar Alchemist 3D lab. Each hypothesis and its numeric predictions were committed on 16 September, the day before the measurements, which is what lets a row below say refuted. Of the 61 predictions, 19 were wrong in whole or in part and 2 could not be measured. Measured on an RTX 5080 in Chromium 153, GPU times from WebGPU timestamp queries. Detail in lesson 14.
| Question | Hypothesis | Result | Verdict |
|---|---|---|---|
| Which way of drawing 16 guitar necks costs least: GA’s own, instanced, batched or merged? | Draw calls drive CPU time; the GPU barely moves between them | Batched on WebGPU drew 44 calls, not the 4 or fewer predicted. GA’s 16 necks cost 2.9 ms of CPU, not the 10 ms or more predicted | Mostly refuted |
| Rewriting a vibrating string’s vertices on the CPU against doing it in TSL, up to 600 strings | At 600 strings the CPU rewrite costs more, and the GPU cost is about the same | CPU 1.8 ms against TSL 0.36 ms, a factor of 5, as predicted. But TSL cost 1.7 times the GPU time of the CPU version | Confirmed on the CPU, refuted on the GPU |
Picking one note among 10,000: Raycaster against a GPU ID pass |
The GPU pick wins at 10,000 objects | Raycaster 0.465 ms against 3.395 ms for the GPU pick |
Refuted |
| Transposing 49,152 pitch-class bracelets, on the CPU against TSL | The CPU pays for rebuilding 49,152 matrices a frame: 4 ms or more | CPU 2.455 ms, below the prediction. TSL 0.29 ms, as predicted | Confirmed on TSL, refuted on the CPU |
| An OPTIC-K point cloud up to 1,000,000 points, and whether TSL compute needs WebGPU | Points stay cheap to a million; the compute shader does not run on WebGL 2 | Points held. The compute shader did run on WebGL 2, through transform feedback, at 2.8 GPU ms | Confirmed on the points, refuted on the backend |
| What the wood textures cost uncompressed, and what KTX2 saves | RGBA8 costs about 22 MB; KTX2 in BC7 costs about a quarter of it | 21.33 MB confirmed. The KTX2 figure could not be read: renderer.info counts 0 for compressed textures |
Half confirmed, half not measurable |
A grid of PBR materials: how many shader programs, and what MeshPhysicalMaterial adds |
One program per material type, shared across the grid; the physical material costs more GPU time | 16 programs either way, as predicted. The physical material cost 1.46 times the GPU time | Confirmed |
| What a dynamic shadow map costs against a baked one | A dynamic shadow map roughly doubles the draw calls | 13 draw calls against 8, a factor of 1.6. The bake took 398 ms, as predicted | Roughly confirmed |
| What bloom costs at 1080p | Between 0.5 and 1.5 GPU ms | 0.135 ms, four times below the lowest prediction | Refuted |
| A wall of 1,000 guitars with LOD and frustum culling | LOD divides the triangles by about 10 | By 11.2 | Confirmed |
| Whether Rapier’s CCD stops a kinematic pick going through a string | CCD prevents the pass-through | The pick passed through at 30 and at 60 steps a second, CCD on or off | Refuted |
| Whether a fretboard is at 1:1 scale in WebXR, under IWER emulation | Nut to twelfth fret measures 0.324 m | 0.324 m | Confirmed |
| How far ahead a note has to be scheduled for WebAudio and the frame to agree | The naive lead is about the sum of the latencies, 50 ms, within one frame | 31.8 ms, 18 ms short of the sum | Refuted |
| What React Three Fiber costs against vanilla three.js, for the same scene | Cheap per frame, costly at startup (+30 to 150 ms) and in the bundle (+50 to 90 kB) | Cheap per frame, as predicted. First frame +430 ms and bundle +195 kB, both far above | Confirmed per frame, refuted on both costs |
| Whether an adaptive pixel ratio settles on a weak GPU | GPU time is proportional to the pixel count, and the controller settles within 60 frames | It settled at 0.655 within 60 frames | Confirmed |
2026-09-16 — Versions
Section titled “2026-09-16 — Versions”npm view threegives 0.186.0, released on 8 September 2026, and@types/three0.186.0. The course pins them exactly incode/threejs/package.json, with Vite 8.3.0, TypeScript 7.0.2, Playwright 1.63.0 and its Chromium 153, glTF Transform 4.5.0,draco3dgltf1.5.7,meshoptimizer1.2.0 andpngjs7.0.0, on Node.js 24.21.0 in CI.- Release r186 is the npm version
0.186.0. The lessons cite the source at ther186tag of mrdoob/three.js. require('three/package.json')fails: the package’sexportsmap doesn’t list it. The version check incheck.shreads the file withfsinstead.- The documentation pages are at
https://threejs.org/docs/pages/<Class>.htmlandhttps://threejs.org/manual/pages/<page>.html; the lessons link those URLs, all checked.
2026-09-16 — Measuring pages in a headless browser
Section titled “2026-09-16 — Measuring pages in a headless browser”- Each lesson page reports what the renderer did through a
window.probepromise, andscripts/probe.mjsstarts Vite, opens the page with Playwright at 800 by 450 pixels and a pixel ratio of 1, and prints the report. Pages that animate use fixed steps when probed, so the third frame is the same on every machine. - My first probe hung on the
WebGLRendererpage: the page replacedwindow.probeafterpage.evaluatehad started to wait for the old promise. The pages now resolve a promise created once, insrc/probe.ts, andprobe.mjsgives up after 30 seconds. - Colors are read from the screenshot, at the positions the page reports.
- Playwright’s default headless mode on Windows gives WebGPU on the author’s RTX 5080 (“nvidia, blackwell”).
chromium-headless-shell, the lighter build, has no WebGPU adapter:WebGPURendererfalls back to WebGL 2, on SwiftShader.check.shruns lesson 1 in both.
2026-09-16 — What the CI runners render with
Section titled “2026-09-16 — What the CI runners render with”- The first CI run, 35114590607, failed on all three OSes, for three reasons: the
coordinateSystemreported by the page follows the backend; a few colors differ by one from the author’s GPU; and on macOS, Vite’s warning, on stderr, came before its stdout lines. The probes now filter the backend-dependent lines,compare.mjsaccepts a difference of 2 per channel in colors, and the build’s stderr is printed after its stdout. - Run 35115118564 passed. Its logs show what each runner draws with.
ubuntu-24.04andwindows-2025-vs2026have no GPU: WebGPU is unavailable, and bothWebGPURendererandforceWebGLrun WebGL 2 on SwiftShader (Subzero).macos-26-arm64has WebGPU, on an “apple” adapter, and WebGL 2 on “ANGLE Metal Renderer: Apple Paravirtual device”; its headless shell uses SwiftShader (LLVM 10.0.0). - Two colors differ by one on the runners: ACES maps linear 0.18 to 127 on the author’s GPU and 128 in CI, and Neutral maps 0.05 to 33 and 34.
- So a green CI run proves that the scenes build, load, and produce the same counts and colors, within 2, on a software rasterizer and on Apple’s GPU. It doesn’t prove anything about WebGPU on Windows or Linux.
2026-09-16 — Surprises
Section titled “2026-09-16 — Surprises”WebGPURenderercounts one more draw call than the scene has. With tone mapping, or with an output color space different from the working space, it renders the scene into a target and draws the result in an output pass: 2 draw calls and 13 triangles for one cube.WebGLRendererconverts in each shader and reports 1. The two renderers’infoobjects also have different shapes.@types/threedescribestoJSON()wrongly. In 0.186.0,Object3DJSONdeclareschildrenas strings and has nogeometriesormaterials, while the runtime writes objects and both arrays. Lesson 1 keeps the compiler errors inerrors/l01_tojson_types.ts. I haven’t looked for an existing issue in three-types/three-ts-types, and haven’t opened one.- The shadow camera’s box is read once by
WebGPURenderer.ShadowNodecallsupdateProjectionMatrix()when it builds the shadow; a change after that needs your own call. PCFSoftShadowMapis gone fromWebGPURendererin r186 and warns. GA’sLunarLanderEngine.tsandMinimalThreeInstrument.tsxstill use it withWebGPURenderer.- A point light of 800 lumens is 63.66 candela, and a spot light of 800 lumens 254.65:
SpotLight.poweruses π, not 4π, and doesn’t change with the cone’s angle. - The PMREM costs 17 renders for a 256 by 128 environment: 1 into the first level, then 2 for each of the 8 others. It happens even when
scene.environmentis a plain equirectangular texture. - Since r185,
DRACOLoaderfinds its decoders withimport.meta.url, which r184 didn’t. Vite then copies all five decoder files into the build, 1.31 MB, when a browser loads two. The build also warns thatthree.webgpuis over 500 kB. - Draco and meshopt drop vertices. Both drop the two pole vertices that
SphereGeometrycreates and no triangle uses; Draco’sweld()also merges the non-indexed body’s corners. - glTF Transform’s
meshopt()printsprune: Removed types... Accessorlines on stdout; they are inexpected/as they are.
2026-09-16 — GuitarAlchemist/ga
Section titled “2026-09-16 — GuitarAlchemist/ga”The course reads GA at commit 05c8eda, which uses three 0.180. 139 files under src directories import three; 33 of them call requestAnimationFrame, 3 setAnimationLoop; 29 create a WebGLRenderer and 8 a WebGPURenderer; 15 use Clock, deprecated in r183. Nothing below has been reported to GA.
ThreeFretboard.tsxexpectsWebGPURendererto throw when WebGPU is missing, and says “Using WebGPU renderer” on every machine, since the renderer falls back to WebGL 2 instead (lesson 1). Its shadows are enabled only for aWebGLRenderer, so the fallback path gets none (lesson 2).ThreeFretboard.tsxasks forsamples: 8; the WebGPU specification allows 1 or 4 samples for a texture. What r180 does with 8 on each backend is to verify.- Its point light of 0.6 cd with
distance: 20gives about 5 % of the main light where it is closest to the neck (lesson 2), and a single canvas texture serves as color, roughness, normal and bump map. - Its wood texture has no
colorSpace, so it shows lighter than painted, and its environment is an 8-bit gradient whose PMREM the renderer builds anyway (lesson 3). How much the wood color differs on screen is to verify in a browser. Guitar3D.tsxbuilds a black environment withfromSceneon a scene that holds only an ambient light, and announces KTX2 and meshopt support without setting either decoder (lessons 3 and 4).Ocean.tsxloads Draco’s decoders from gstatic.com;DemerzelFaceOverlay.tsxhides KTX2 errors by replacingconsole.errorduring a load;HandAnimationTest.tsxupdates anAnimationMixerthat has no action (lesson 4).Experiments/ThreeJS-BSP-LoaderimportsWebGPURendererfrom a path removed in r167; the dashboard’s embedding viewer removes a resize listener with a newbind, which removes nothing.ThreeHeadstock.tsxadds a click listener to the same canvas on each run of its effect and never removes it;TonalOrbit.tsxandMinimalThreeInstrument.tsxraycast on every mouse event, the second with a newRaycastereach time; Prime Radiant’sInteractionHandler.tsdoes it the lesson’s way; 4 of the 25 files withOrbitControlsnever dispose them (lesson 5).- The GLSL sky of
Sunburst3D.tsx, copied inImmersiveMusicalWorld.tsx, can’t run onWebGPURenderer;FresnelGlowTSL.tsbakes its corona’s intensity and color into the shader as constants;MoebiusPassTSL.tsis a GLSLShaderPassdespite its name (lesson 6). Ocean.tsxandLunarLanderEngine.tsusePostProcessing, renamedRenderPipelinein r183, andOcean.tsxnever disposes the pipeline or its nodes;ForceRadiant.tsxruns onWebGPURendererwithout the bloom andShaderPasseffects of its WebGL path, a “follow-up task”; the 10 files withEffectComposeruse it correctly for WebGL (lesson 7).ThreeFretboard.tsxcreates a geometry and a material per fret and per marker, rebuilds its scene whenever itspositionsprop is a new array, including the default[], and doesn’t dispose its 29 sprites;NodeInstancer.tsinstances its nodes well, with culling turned off; GA uses neitherBatchedMeshnorTHREE.LOD(lesson 8).
2026-09-16 — Lessons 5 to 8: pointers, shaders, effects, counts
Section titled “2026-09-16 — Lessons 5 to 8: pointers, shaders, effects, counts”- Real pointer events in a headless page.
scripts/probe.mjsnow performs the moves and drags a page lists, with Playwright’s mouse, and asks the page what it saw after each one. Chromium turns them intopointermove,pointerdownandpointerupevents, whichOrbitControlshandles as it would a user’s: a 120-pixel drag turned the camera by 96°. - A camera outside the scene has a stale matrix in a script. The first version of
l05-raycaster.tsprojected a point to an NDC of −3.6: rendering updates the camera’s world matrix, and a Node.js script doesn’t render.camera.updateMatrixWorld()fixed it. - A shared stylesheet is part of every chunk’s hash. Adding lesson 5’s layout rules to
page.csschanged the names of all the build’s chunks, including the04-gltf-CCDSPzCq.jsthat lesson 4 quotes. Lesson 5 got its own stylesheet, andcheck.shbuilds the pages of lessons 1 to 4 alone, with aPAGESfilter, before building everything: with the pages of lessons 5 to 8, Rolldown names the shared WebGPU chunkthree.tslinstead ofthree.webgpu. PostProcessingisRenderPipelinesince r183, andpipeline.dispose()frees one material. The bloom’s 11 render targets and the scene pass’s one stay until the nodes themselves are disposed.DirectRenderPipeline.rendertakes the scene and the camera, unlikeRenderPipeline.render(), so TypeScript rejects it in aRenderPipelinevariable; and it draws a solid background as a sphere of 1,984 triangles.renderer.info.renderadds up acrossrender()calls within one animation frame. A page that rendered 60 frames in a loop to time them reported 610,061 draw calls; lesson 8 reads the counts first.
2026-09-16 — The CI runners and lessons 5 to 8
Section titled “2026-09-16 — The CI runners and lessons 5 to 8”- Run 35123228046 passed on macOS (WebGPU on the “apple” adapter) and failed on Linux and Windows, where
WebGPURendererfalls back to WebGL 2 on SwiftShader. Four differences, all real:- The bloom chains count one more program on WebGL 2: 14 instead of 13, 16 instead of 15.
- With FXAA, SwiftShader’s halo pixel is 135, 176, 197, where the author’s GPU gives 132, 174, 195 on both backends: 3 more than
compare.mjsaccepts. - A
BatchedMeshis drawn withWEBGL_multi_drawand counted as 2 draw calls, not 10,001. - The WebGPU shader file holds GLSL, since the page ran on WebGL 2.
- Lesson 8’s pages with 800,000 triangles timed out: the probe result arrived, but Playwright’s screenshot waited more than 30 seconds behind the 60 timed frames still queued in the software rasterizer.
- The fix:
check.shcompares a probe withexpected/<name>.webgl.txtwhen that file exists and the page ran on WebGL 2. The five files come fromchromium-headless-shellon the author’s machine, whose SwiftShader gave the same values as the runners. The GLSL line is compared, and the WGSL line only printed. Lesson 8 times 20 frames, and its probes wait up to 240 seconds (PROBE_TIMEOUT). Run 35124756498 passed on the three OSes.
2026-09-16 — Lessons 9 to 13: versions
Section titled “2026-09-16 — Lessons 9 to 13: versions”- React 19.3.0 came out on 9 September 2026, but
@react-three/fiber9.7.0 declaresreact>=19 <19.3: the course pins React 19.2.8, with drei 10.7.8. R3F 10 and drei 11 are alphas.@react-three/test-renderer9.1.1 and Vitest 5.0.1 test the components. - Rapier 0.20.0 was published on 8 August 2026. three.js r186’s
RapierPhysicsaddon still loads 0.17.3 from skypack.dev, and@react-three/rapier2.2.0 pins 0.19.2. The course uses both@dimforge/rapier3d-compatand@dimforge/rapier3d-deterministic-compat; their classes have private members, so TypeScript refuses one module’s type for the other without a cast. - IWER 2.4.0 emulates a Quest 3; pixelmatch 7.2.0 compares screenshots.
2026-09-16 — Lessons 9 to 13: what the pages found
Section titled “2026-09-16 — Lessons 9 to 13: what the pages found”- R3F 9.7.0 warns on
WebGPURendererwithout being asked.<Canvas>’sshadowsdefaults tofalse, and R3F setsPCFSoftShadowMapfor any boolean;WebGPURendererresets it with a warning, once per render of<Canvas>. Its store also creates a deprecatedTHREE.Clock. - drei’s
<Text>draws a blank quad onWebGPURenderer: troika-three-text injects its shader throughonBeforeCompile, which the node-based renderer never calls. - Render counts depend on the machine. In
chromium-headless-shell, 10 state updates one frame apart gave 6 renders instead of 11, and a 5-step mouse move fewer renders than on the GPU: React batches updates and the browser coalesces pointer events when frames are slow. The pages useflushSync, and report no counts after pointer actions. - Chromium on Windows warns that
powerPreferenceis ignored whenever R3F passes it; the other OSes don’t.check.shfilters the line. - The deterministic and standard Rapier builds gave the same state hash on Windows x64, in Node.js and in the browser. With a variable step between 1/144 and 1/30 s, 16 of 24 picks landed elsewhere and one fell through a 20 cm floor. A fast ball crossed a thin dynamic wall without CCD, but not a fixed one.
- IWER needs
forceInstallin Chromium, which already has anavigator.xr, andstereoEnabled, or the right eye’s viewport is 0 pixels wide. - The first WebXR probe hung for ten minutes while holding the shared GPU lock: the WebGL 2 backend threw on every XR frame, the page waited for frames forever, and the run was stopped by hand. The cause is IWER’s
XRWebGLLayer.framebuffer,null, used as aWeakMapkey inWebGLState.drawBuffers. The page now catches render errors and bounds its waits, andprobe.mjsexits on its own after twice its timeout plus 30 seconds. - In r186,
WebGPURendererenters a WebXR session on WebGPU only withXRGPUBindingand thewebgpufeature; its error message mentions aVRButtonGPUthat doesn’t exist.WebGLXRFallbackswitches renderers, but the controllers the page took from the first renderer stop receiving events. Only the classicWebGLRendererrendered both eyes with IWER. - An
InstancedMeshignoressetColorAtif its first render had no instance colors: the port’s markers showed white until the attribute was allocated up front. @react-three/test-rendererneedsIS_REACT_ACT_ENVIRONMENT, or React warns on every update and R3F disposes later; its CommonJS build loadsthree.cjsnext to the ES module build, without splitting the classes the scene uses.- Pixels: the same page twice gave identical bytes; WebGPU against ANGLE on the same GPU differed in 294 pixels, against SwiftShader in 10,330, none above pixelmatch’s default threshold once anti-aliasing is left out.
- An image imported by a lesson must exist before the page does: lesson 11 once imported a screenshot not yet converted, and the shared working tree’s Astro build failed for the other sessions working in it until the image was added.
2026-09-16 — Lessons 9 to 13 on the CI runners
Section titled “2026-09-16 — Lessons 9 to 13 on the CI runners”- Run 35175914560 passed on all three OSes the first time. The standard Rapier build gave the state hash
68235a2bdba192f1on Linux x64 and macOS arm64 too, in Node.js and in the browser, the same as the deterministic build: for this scene, and not as a guarantee. - macOS ran the pages on WebGPU with the “apple” adapter; Linux and Windows on SwiftShader’s WebGL 2, where GA’s
ThreeFretboardproduced its 253 WebGL error lines and R3F warned 13 times aboutPCFSoftShadowMapinstead of 14. - Before the push, the local check compared lesson 11’s fallback page with its WebGL 2 output on the author’s GPU: the page ends on WebGL 2 after starting on WebGPU.
check.shnow uses the WebGL 2 output only when the page ran on WebGL 2 from the start.
2026-09-16 — GuitarAlchemist/ga, lessons 9 to 13
Section titled “2026-09-16 — GuitarAlchemist/ga, lessons 9 to 13”Nothing below has been reported to GA.
- GA’s React components use R3F 8, drei 9 and React 18, with
WebGLRendererin all 6<Canvas>files;ThreeFretboard.tsxdoesn’t use R3F. The BSP explorer sets React state with a cloned camera position on every frame, andGuitarAlchemistLogo3D.tsxpasses a new object inargs, rebuilding its geometry on each render (lesson 9). - No physics library: Cheese Avalanche is hand-written with a variable step clamped to 1/30 s; the lunar lander uses a fixed 1/120 s step with an accumulator (lesson 10).
- No WebXR code (lesson 11).
- 22 Playwright spec files, of which the 7 outside the dashboard suite, 6 of them on 3D pages, run in no CI workflow; 3-second waits for WebGPU; a
toBeTruthy()on a screenshot buffer; a black-canvas check that callsgetContext('2d')on a WebGL canvas and can never pass; 13 committed test result files from a failed run (lesson 12). ThreeFretboard.tsx, reproduced and measured: 69 draw calls and 32 textures for an empty neck; 10 renders of its parent with the same notes rebuild the scene 10 times and leak 29 sprite textures each time; a pixel ratio of up to 6;samples: 8, which draws nothing on SwiftShader’s WebGL 2; strings twice as thick, since a gauge is used as a radius; and anonPositionClickprop that is never called. The port draws the same neck in 6 draw calls with 4 textures, and picks notes (lesson 13).
2026-09-17 — Live demos
Section titled “2026-09-17 — Live demos”- The pages are built by a second Vite config,
vite.demos.config.ts, with the base/learn/threejs-demos/, intopublic/threejs-demos, which the site copies as it is. The lesson pages’ sources are unchanged, since lessons 4, 8 and 13 quote their build; a plugin of that config adds the Try it panel to the built pages only. A page can’t be driven from outside, so each control sets a URL parameter and reloads the page. The panel is lil-gui 0.17, the copy that three.js ships inthree/addons/libs: no new dependency. - The build weighs 9.4 MB in 75 files. Rapier’s standard and deterministic builds, each with its WebAssembly inlined as base64, are 2.9 MB each;
three.tsl0.69 MB.check.shbuilds it again and compares it with the committed copy (diff -r, apart from the regenerated models and HDR). - The frames sit in a closed
<details>, withloading="lazy": nothing loads until a reader unfolds one. - The guitar is modeled in code: no glTF guitar with a known license was available. 47,405 triangles in 32 draw calls; instancing for the frets, inlays, pole pieces, saddles, knobs and tuners.
- Rapier’s
body.handleis a float that packs an index and a generation:handle % 8is not a palette index; each pick now keeps the color of its throw number. And colors set withsetColorAtreached the GPU only in a frame ofsetAnimationLoop: a probe that calledrender()itself saw white picks. In r186,InstanceNodeuploads them in its per-frame update. - The VR page uses the classic
WebGLRenderer, as lesson 11 found necessary with IWER. During a session, itsresizelistener must do nothing:WebGLRendererwarns “Can’t change size while VR device is presenting”. - On a phone-sized page (Playwright’s Pixel 7 emulation), the frames first measured 150 pixels tall: the site’s styles override the iframe’s
heightattribute, so the height is now an inline style,min(420px, 70vh). The panel starts folded in a narrow frame. With a 4× CPU slowdown but the author’s desktop GPU, the five scenes held 60 frames per second on WebGPU and with?webgl; on a real mid-range phone GPU it is to verify. - The first probe of a page that imports a new dependency failed with “Execution context was destroyed”: Vite optimized the dependency and reloaded the page. The second run passed.
2026-09-17 — Lesson 14, the Guitar Alchemist 3D lab
Section titled “2026-09-17 — Lesson 14, the Guitar Alchemist 3D lab”- Fifteen experiments in
code/threejs/ga-lab, each with a hypothesis and numeric predictions committed on 16 September before its run, measured on 17 September on an RTX 5080 in Chromium 153 with WebGPU timestamp queries. Of 61 predictions, 19 were wrong in whole or in part and 2 couldn’t be measured; they stay in the lesson. - Frames are rendered in a tight loop without
requestAnimationFrame, vsync off, and the page advancesrenderer._nodes.nodeFrameitself: without it, shadow maps and bloom don’t update.onSubmittedWorkDone()never resolved under about 3.5 ms, so GPU times come from timestamp queries. - r186 findings:
InstancedMeshwith morph targets fails with one instance (nomorphTargetInfluences) and with several when they are set;BatchedMeshdraws one call per object on WebGPU; TSL compute runs on WebGL 2;renderer.info.memory.texturesSizecounts 0 for compressed textures. - GA’s OPTIC-K index has 313,047 vectors of 124 dimensions (OPTK v4), not 112. The lab reads it read-only; CI uses a synthetic stand-in.
- Rapier’s CCD doesn’t stop a kinematic pick: it passes through a string at 30 and 60 steps per second either way.
- R3F’s build brought
threenext tothree/webgpu: 565 kB gzip against 210 for the same scene without React. - The ComfyUI wood textures are pending: the ComfyUI GA lab’s experiment 4 had no output yet.
- CI (
threejs-ga-lab.yml) comparesrenderer.infocounters on WebGL 2 on three OSes, never times. A Playwright screenshot hung for 300 s once on macOS: the runner now keeps the result when a screenshot fails.
2026-09-24 — Upstream fixes
Section titled “2026-09-24 — Upstream fixes”- The
PCFSoftShadowMaprow does not apply to GA yet. The removal and its warning are real in r186 (Renderer.jsandShadowNode.jsof thethree@0.186.0package), but GA pins three 0.180.0 and @react-three/fiber 8.18.0. In 0.180,ShadowNodestill mapsPCFSoftShadowMapto its soft filter, so there is no warning and the soft shadows work. - GA’s two pairings with
WebGPURenderer, inLunarLanderEngine.tsandMinimalThreeInstrument.tsx, will start warning when GA moves to r186 or later. Changing them now would lose the soft shadows, so #733 left them for that upgrade and says so. Read in the package sources, not run (to verify).
To verify
Section titled “To verify”- KTX2 GPU memory with a GPU tool.
- R3F’s first frame on a production build.
- A
threealias tothree/webgpu. - The real-size neck and IWER’s 25 mm head offset on a headset.
- Audio latency with a microphone.
powerPreferenceon a laptop with two GPUs.- The VR demo with a real headset: entering the session, the controller rays, the note and the haptic pulse.
- Whether picks thrown without CCD pass through the frets of the physics demo.
- The five complete scenes on a phone: frame rate, and whether the studio’s 2048 × 2048 shadow map and bloom hold up.
- Whether a scene modeled in centimeters needs lights 10,000 times stronger to look the same as in meters (lesson 2).
- KTX2 textures: loading, GPU formats chosen on each OS, memory.
- Whether
ThreeHeadstock.tsxcallsonTuningPegClickonce per stale listener after its effect reruns (lesson 5). - Whether
ThreeFretboard.tsxis rebuilt on every render of its parents in GA’s pages (lesson 8). - GPU time, not only CPU time, with WebGPU’s
timestamp-query(lesson 8). - Draco against meshopt on a large model and on many small ones, over a real network (lesson 4, exercise 3).
- WebGPU on a Linux or Windows machine with a GPU, outside the author’s.
XRGPUBinding: which browsers and headsets offer it, andWebGPURendererin a real WebXR session (lesson 11).- The WebGL 2 backend and
WebGLXRFallbackon a real headset, whose layer framebuffer isn’tnull(lesson 11). - Frame rate, foveation and comfort of the WebXR page on a Quest (lesson 11).
- Whether a WebGPU-ready troika-three-text exists for drei’s
<Text>(lesson 9). - How often GA’s parents render
ThreeFretboardand the BSP explorer in practice, with React’s profiler (lessons 9 and 13). samples: 8on real GPUs’ WebGL 2, byMAX_SAMPLES(lesson 13).- Why Rapier stopped a fast ball at a thin fixed wall without CCD (lesson 10).