Skip to content

Upgrade three.js to 0.187.0 (PRD-775) - #597

Open
jonit-dev wants to merge 68 commits into
developfrom
prd-775-three-0.187
Open

jonit-dev wants to merge 68 commits into
developfrom
prd-775-three-0.187

Conversation

@jonit-dev

@jonit-dev jonit-dev commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What is this PR about?

It moves every package, template and generated game from three.js 0.185.1 to 0.187.0. It rebases the ThreeNative patch onto the new release, and ports the r186/r187 rendering changes into the owned C++ engine, so the native and Wasm engines still match three pixel for pixel.

Why does it matter?

Games get two releases of upstream fixes, such as the ACES fit, energy-conserving lighting, PMREM cube targets and watertight raycasts. The patch stops drifting from three.js.

Done

  • ✅ Phase 1 (AC-1..3): every pin is on 0.187.0, the patch is rebased hunk by hunk (ledger below), and the installer specs are green.

  • ✅ Native engine ported to r187: lighting (shared DFG, multi-scatter, roughness floors), TRAA, PCF shadows, MaterialX noise, the watertight ray, the lathe normal, GTAO, the bloom tap merge, the sRGB build exponent, the denoise precision and the PMREM cube mip chain. ctest -L native-engine: 527/527.

  • ✅ Real bugs found and fixed on the way:

    • The patch's async PMREM bake bound a 1-layer placeholder as a cube. Dawn dropped the whole command buffer, so every environment-lit scene drew black until the bake landed.
    • The native host lacked GPURenderBundleEncoder (ReferenceError on every draw).
    • Node building before renderer.init() crashed.
    • The procedural-animals probe leaked its storage buffers.
    • The starter's TRAA used the r185 view-offset hand-off, and reported one extra reset frame on r187.
    • The engine's compute warm-up passed a dispatch size that r187 refuses (Renderer.js:3238), so ocean and fluid warm-ups compiled nothing.
  • ✅ Dropped the patch's transient output-depth hunk (−1208 patch lines). r187 draws the RenderPipeline output at one sample, so the hunk never ran.

  • ✅ pnpm parity vs develop 6bf21eab2: every red this PR added is fixed. The remaining reds (web temporal-aa-scaled, -lifecycle, vfx-spawn-requests, realism-velocity; desktop temporal-aa, realism-*) fail the same way on develop.

  • ✅ The reference capture now fails closed on a WebGPU validation error and on a single-colour frame.

  • ✅ Phases 2 and 3 (AC-4..8): typecheck, lint, unit tests and parity are green; every kit plays from spawn; the visual judge returns NEUTRAL or IMPROVEMENT for every template; perf is NEUTRAL on web and native desktop.

  • ✅ AC-8 perf (presented p95, 5 paired runs per lane, RTX 2080): Midway web 16.68 / 16.68 ms, stress lab web 16.68 / 16.68 ms, Midway desktop 18.15 / 18.41 ms (+1.4 %), stress lab desktop 17.07 / 17.02 ms. All NEUTRAL. pnpm native:verify:desktop exit 0.

  • ✅ CI on c38dcde74 (run 41248394799): 65 jobs pass. CI found 7 PR-only reds over three runs, all fixed:

    • exposure warm-up and cold-boot proofs reject three's first node frame at time 0; velocity's no-history control is exact on r187 (8ee2f524b)
    • 3 parity rows split by SwiftShader vs llvmpipe on r187's half-float DFG LUT now need a hardware adapter; NVIDIA native = web, 0 px (3d50a8dbc)
    • a lint waiver (8e7cc84ee), the native census (4826787f1), and sailing's wind check on its 120 s float boundary (c38dcde74)

CI

  • ✅ Full board green on c3f5598f7, run 41288721287, first attempt: 89 jobs pass, 4 skipped by design (queue-watch and 3 V8 payload jobs), ci-required pass.

  • ✅ The two commits after that run, 2c6954d6e and 4f0a79fad, change only the PRD and the generated Unreal port status files. pnpm check:docs passes on both, and the review lane passes on 4f0a79fad with ci-required.

  • ✅ The shooter engine-cost timeout on run 41265123694 is hosted-VM variance, not r187. A local paired 1260-frame row (4 cores, SwiftShader) read 843 and 1464 s on base against 799 and 861 s on r187. The leg took 17 minutes in the green run.

  • ⚠️ Local rerun on the merged tree (develop 0.3.9), one suite at a time. shooter performance peaks at 128 draw calls, down from 731. It still misses its frame limit at 17.9 ms (GPU). Develop misses it too, at 18.3 to 19.5 ms on the same machine.

  • ⚠️ Head fc0a73183 has no full board of its own. The full run above is on the older head c3f5598f7. The merge queue runs the board.

Notes for review

  • ⚠️ Look changes on purpose, for the AC-7 judge: the r187 ACES fit, the lighting energy rewrite, PCFSoftShadowMap → PCFShadowMap, and r187 GTAO for the 4 templates that author gtaoRadius (action-rpg, rts, runner, shooter). The other 12 templates use the engine's reachPx search, which is unchanged.
  • ✅ The AO denoise pass reads a one-channel field, so it now targets R32Float (099ce70f8); RGBA32Float had cost +0.87 ms GPU on the stress lab.
  • ⚠️ packages/core/src/gpu-frame-observation.ts now wraps r187's self-resolving query pool. It is no longer read-only, so please review it.
  • ⚠️ PRD number collision: develop also filed docs/PRDs/Unreal/shadows/PRD-775-virtual-shadow-pages-… (another lane). This PR's PRD is the tech-debt-code-quality one.
  • Pre-existing on develop 6bf21eab2, not this PR: native_engine_player_gpu_stages (same failure on the base build).
  • Follow-up: core's batched-velocity scheduler may be redundant now that three r187 keeps BatchedMesh history itself (Batch.js:78).

Changed goldens (AC-5): one upstream reason each

One or more upstream causes necessarily move each frame's hash, so a group row is one reason per fixture.
diff data: 94 fixtures differ only in render.pngSha256; exactly one fixture differs in a number.

fixture(s) what changed upstream file:line + reason
78 render fixtures with toneMapping: "aces" — all 78 aces render fixtures, e.g. lit-render, batched-mesh, fog-*, gltf-model-*, materials-*, morph-*, nodemat-*, particles-sprite, pbr-grid-envmap, pmrem-texture, probes*, reflector-plane, screen-uv, shadows-*, skinned-*, sky-equirect, storage-attribute, texture-object, textures-data-3d, tonemap-ramp-aces, tsl-*, viewport-textures, vsm-*, fluid-particles, lights-point-spot, materialx-noise pixel hash, every aces frame src/nodes/display/ToneMappingFunctions.js:69 (r187): b = color*(color*0.983729 + 0.4329510) + 0.238081; r185 was color*(color + 0.4329510)*0.983729 + 0.238081. Upstream: r187 "Fix ACES Filmic fit denominator" #35110. Control: tonemap-ramp-aces moved (strip 1 227→226) while tonemap-ramp-{agx,cineon,linear,neutral,reinhard} and their fixtures did not, so the ACES curve is the only tone-curve change.
12 non-aces fixtures: gltf-model-specular, material-ao-environment, material-normal-map, material-pbr-maps, materials-bump-map, materials-force-single-pass, materials-metal-rough-maps, materials-vertex-colors, materials-vertex-node, textures-data-float, textures-data-half, textures-data-rgba8 pixel hash src/nodes/functions/PhysicalLightingModel.js:329 (r187): direct specular is now irradiance.mul(specularBRDF).mul(multiScatteringCompensation), replacing r185's BRDF_GGX_Multiscatter(… f90: 1 …); indirect at :879-895. Upstream: r186 removed BRDF_GGX_Multiscatter and re-derived multi-scattering energy (#33983, #33985, #34046, #34055), r187 "share dielectric scattering in indirect lighting" #34656, "fix specular intensity in direct lighting" #34764. Every one of these 12 is lit; every non-aces fixture that did not change (alpha-test, alpha-transparency, blending-additive, gltf-model-webp, lines-basic, textures-anisotropy, textures-data-{canvas-mips,filters,wraps}) has no light and no shading material.
pmrem-hdr-equirect, textures-data-equirect pixel hash src/extras/PMREMGenerator.js (r187): the CubeUV atlas is gone, replaced by CubeCamera + cube render targets (MIN_SIZE = 256, src/extras/PMREMGenerator.js:31) — r187 "Replace the CubeUV atlas with cube render targets" #34585, plus r186 "spiral blur" #32367 and r187 "restore minimal roughness, improve roughness ramp" #34645. Both fixtures are unlit metal spheres lit only by the environment.
history-cut, traa-history pixel hash examples/jsm/tsl/display/TRAANode.js:237 (r187): the previous-depth node now samples this._historyRenderTarget.depthTexture instead of its own DepthTexture(1, 1); :330: the jitter wraps over all 32 offsets (% length) where r185 wrapped at length - 1. Same file also drops the post-processing view-offset sync and pre-initialises its targets on resize.
geometry-lathe value: last path point's normal, 25 of 375 components 0.035999998450279236 → 1 (muzzle), 18 of 54 (partial) src/geometries/LatheGeometry.js:106 (r187) adds prevNormal.normalize() before the last vertex's normal is pushed; r185 pushed it unnormalised. Upstream: r187 "Normalize the normal of the last point" #35153.

Subsystem-specific changes that ride inside the groups above and are the reason a reviewer will see those rows move:
tsl-post-smaa (r186 SMAANode fix/simplification #34031), tsl-post-bloom/tsl-post-template-high
(r186 BloomNode blur strategy #33923), tsl-post-ao/tsl-post-ao-raw (r186 GTAONode quadratic stepping,
clip-space march, banding, baked sample count #33895/#33903/#33919/#33931), shadows-*/vsm-* (r187
ShadowFilterNode PCF radius #35018 and per-shadow uniforms #35047, PointShadowNode #34899/#35186,
ShadowNode #34801/#35093), screen-uv (r186 ScreenNode DPR #34186), particles-sprite (r187
SpriteNodeMaterial centre #35064), materialx-noise (r187 RangeNode hash/random-order #34897/#34920),
reflector-plane (r187 ReflectorNode view offset #34917, bounces #34977), lit-render-scaled/
tsl-post-resized (r186 PassNode pixel ratio #34453).

No diff without an upstream reason. Nothing else in the corpus moved: all 97 non-render goldens except
geometry-lathe are byte-identical.

Re-recorded after the PMREM patch fix: pmrem-hdr-equirect, pmrem-texture, textures-data-equirect, sky-equirect, material-ao-environment and pbr-grid-envmap were first recorded as solid black frames, the dropped command buffer described above. They are now real frames (1482 to 25621 colours). pmrem-hdr-equirect is byte-identical to a capture on stock r187. test:reference --check: 205 fixtures, 0 problems.

Dropped patch hunks (AC-2): reason per hunk

The ledger has 153 rows: 82 kept, 33 ported and 38 dropped. The full per-hunk ledger, kept and ported rows included, is in the two PR comments below.

hunk verdict what it did reason
NodeMaterialObserver.js #3 dropped-upstream drop _equal from getGeometryData r187 replaced the flag with a counter: _version: 0 in the same literal (src/materials/nodes/manager/NodeMaterialObserver.js:420 r187) plus `geomet
NodeMaterialObserver.js #4 dropped-upstream drop _equal from getMaterialData r187 r187:536 data = { _renderId: - 1, _version: 0 }.
NodeMaterialObserver.js #5 dropped-upstream drop materialData._equal = false before a material diff return r187 r187:604-686 rewrote the block around changed = true / materialData._version ++, which is this fix done properly.
NodeMaterialObserver.js #6 dropped-upstream same, vector-valued material property branch r187 changed = true in the value.equals branch.
NodeMaterialObserver.js #7 dropped-upstream same, texture-valued material property branch r187 changed = true + value.cacheVersion/value.transform tracking.
NodeMaterialObserver.js #8 dropped-upstream same, scalar material property branch r187 changed = true in the else branch.
NodeMaterialObserver.js #9 dropped-upstream drop the _equal = true / else if _equal === false early-out so eve r187 r187:684-694 replaced it with the per-render-object renderObjectData.materialVersion !== materialData._version mismatch, plus an output-target
NodeMaterialObserver.js #10 dropped-upstream drop geometryData._equal = false, removed-attribute branch r187 changed = true; continue;.
NodeMaterialObserver.js #11 dropped-upstream same, changed-attribute branch r187 changed = true.
NodeMaterialObserver.js #12 dropped-upstream same, attribute-set changed branch r187 changed = true.
NodeMaterialObserver.js #13 dropped-upstream same, index changed branch r187 changed = true.
NodeMaterialObserver.js #14 dropped-upstream same, draw-range changed branch r187 changed = true.
NodeMaterialObserver.js #15 dropped-upstream drop the geometry _equal = true / else if early-out r187 r187:805-820 if ( changed === true ) geometryData._version ++; + renderObjectData.geometryVersion !== geometryData._version.
Batch.js #1 dropped-upstream import positionPrevious for the motion-vector batch transform r187 src/nodes/accessors/Batch.js:2 imports { positionLocal, positionPrevious } already.
Batch.js #2 dropped-upstream previousMatricesTexture = `batchMesh._previousMatricesTexture ?? mat r187 r187:88-108 getPreviousMatricesTexture( batchMesh ) creates the texture from _matricesTexture on first use (and again after BatchedMesh dis
Batch.js #3 dropped-upstream assign positionPrevious from the previous batching matrix when `need r187 r187:146-167 does exactly this: createBatchingMatrixNode( getPreviousMatricesTexture( batchMesh ), indirectId ) → `positionPrevious.assign( pre
Batch.js #4 dropped-upstream drop the two trailing blank lines at EOF cosmetic only; r187 keeps them. Nothing to port.
Instance.js #3 dropped-obsolete force the interleaved-attribute path in instance() and track the buf r187 deleted the whole interleaved tracking: no _matrixBuffers, no interleavedMatrix/interleavedColor locals; the columns are `new InterleavedBu
Instance.js #4 dropped-obsolete sync the interleaved buffers on OnBeforeFrameUpdate The block this changed does not exist in r187 (same deletions as row 3); r187 uploads on attribute version change instead.
Skinning.js #1 dropped-upstream previousBoneMatricesData() — per-skeleton history created at the cur r187 setupPreviousBoneData( skeleton ) (r187:159-187) does the same, storing on the skeleton (previousBoneMatrices, previousBoneTexture, both de
ShadowFilterNode.js #2 dropped-upstream PCFShadowFilter uses the shared mapSize / radius r187:85-86 const { mapSize, radius } = getShadowUniforms( shadow );.
ShadowFilterNode.js #3 dropped-obsolete PCFSoftShadowFilter uses the shared mapSize PCFSoftShadowFilter was deleted upstream (r187 src/nodes/lighting/ShadowNode.js:117 keeps null /* PCFSoftShadowMap, removed */ in `_shadowFilter
PMREMNode.js #6 dropped-obsolete Y flip only for generated PMREMs, read off the live texture (`this._te The whole Y-flip block and textureCubeUV/_width/_height/_maxMip were removed upstream; r187 samples the generated cube texture directly (`this
RenderObject.js #1 dropped-upstream Track the version of _sourceMaterial this render object was validate r187 added the same field for the same reason: this.sourceMaterialVersion = this._sourceMaterial !== null ? this._sourceMaterial.version : 0; (Rende
RenderObject.js #2 dropped-upstream getChainArray() must include the source material so override and non r187 returns [ object, material, context, lightsNode, this._sourceMaterial ] when _sourceMaterial !== null, which is the same discriminator (ours
RenderObject.js #3 dropped-upstream getGeometryCacheKey( useMorphTargetIds ) — a pipeline key must not d r187 added WebGPUAttributeUtils.getVertexBufferLayoutCacheKey( renderObject ) to the pipeline cache key and removed stride/offset/index from the geo
RenderObjects.js #1 dropped-upstream Chain key must include the source material (_chainKeys[ 4 ]) so mate r187 introduced _overrideChainKeys (5 slots, [ null, null, null, null, null ]) used only while an override is active, with `chainKeys[ 4 ] = sourc
RenderObjects.js #2 dropped-upstream Version gate must answer with the source material's version while draw r187's gate is `renderObject.version !== material.version \
RenderObjects.js #3 dropped-upstream Reset _chainKeys[ 4 ] after the lookup r187 resets chainKeys[ 4 ] only when sourceMaterial !== null, which matches its 4-vs-5 slot scheme
Renderer.js #9 dropped-upstream Write overrideMaterial._alphaTest so the shared override material's r187 has the same assignment with the same rationale: `overrideMaterial._alphaTest = material.alphaTest; // bypass the setter so the version doesn't c
Renderer.js #13 dropped-upstream Run every draw step unconditionally instead of behind `nodes.needsRefr r187 replaced the boolean with RenderObjectRefreshType (constants.js:1711) and _renderObjectDirect gates FULL / SHARED / NONE (Renderer.js:4
Renderer.js #14 dropped-upstream Always call nodes.updateAfter( renderObject ) after a draw r187 calls it for every refresh type except NONE (if ( refreshType !== RenderObjectRefreshType.NONE ) this._nodes.updateAfter( renderObject )) and
WebGPUBackend.js #11 dropped-upstream Bind-group comparison must compare the native bind group object, not t r187 does exactly this with one call instead of two: `const bindGroupGPU = this.get( bindGroup ).group; … if ( currentBindingGroups[ i ] !== bindGroup
WebGPUBackend.js #12 dropped-upstream Store the native bind group object in the per-pass binding cache as row 11 — currentBindingGroups[ i ] = bindGroupGPU;
WebGPUBackend.js #16 dropped-upstream Pipeline key uses the structural geometry key without morph-target ids r187 replaced that entry with utils.getStripIndexFormat( object, geometry ) + this.attributeUtils.getVertexBufferLayoutCacheKey( renderObject ), w
WebGPUBackend.js #20 dropped-obsolete (hunk touched only a blank line before copyTextureToTexture's mipmap our hunk adds one empty line and changes no behaviour; r187's guard is `dstLevel === 0 && dstTexture.generateMipmaps === true && dstTexture.mipmapsAut
WGSLNodeBuilder.js #3 dropped-upstream Do not name uniform buffers with a process-wide allocation id (it chan r187 removed the very line our hunk rewrote (uniformNode.name = name ? name : 'NodeBuffer_' + uniformNode.id; is gone from getUniformFromNode), so
WebGPUTextureUtils.js #4 dropped-upstream The canvas colour texture is only reusable when its GPU texture exists r187 made both changes: if ( colorTextureData.texture !== undefined && colorTexture.width === width && … ) and colorTexture.image.width = width;
WebGPUBackend.js #21 (transient output depth: also Renderer.js, RenderPipeline.js, WebGPU{Attribute,Binding,Pipeline}Utils.js) dropped-obsolete delete _beginTransientDepth/_finishTransientDepth/_restoreTransientDepth/_isFullTransientViewport/_validateTransientDepth and the _tnOutputOwner/_tnDepthProof/_tnUniformBytes/_tnFullTriangle full-quad proof dead on r187: RenderPipeline output is a 1-sample fullscreen pass (Renderer.js:2198, :3004)

PRD: docs/PRDs/done/PRD-775-upgrade-three-to-0.187.md

@jonit-dev jonit-dev added the prd:0% PRD implementation has no verified phase yet label Oct 30, 2026
… engine patch (PRD-775)

Ports the 0.185.1 patch onto 0.187.0 by 3-way merge plus per-hunk intent review,
regenerates the bundle hunks from the r187 tag (stock build == npm, byte for byte),
drops the PRD-269 0.185.1 predecessor migration, and overrides three for published
packages that still pin 0.185.1. Patch specs are reworked in the next commit.
…h null previous matrices (PRD-775)

- @types/three 0.187: TextureNode instead of ReturnType<typeof texture>, QuadMesh(material).
- getShadowMaterial(light) moved onto the shadow node (same per-light cache).
- WorldCells and TerrainTiles call super.dispose() (r186 Object3D.dispose dispatches the dispose event).
- PCFSoftShadowFilter is gone upstream (r186), so its shared-render-group case is dropped.
- BatchedMesh._previousMatricesTexture is null when absent in r187; the velocity lane checks != null.
- Patch: PMREM async face 5 restores mipmapsAutoUpdate (source mips), every draw step runs again.
…hunk classification (PRD-775)

- classifyFile only shifts later hunks by hunks already patched on disk. Adding stock hunks'
  line deltas put the preferred position halfway between two identical generated blocks in
  build/three.tsl.js and threw TN_THREE_PATCH_INVALID (ambiguous hunk location).
- PRD-269 migration tests deleted with the migration; clean install, idempotence and tamper
  refusal kept. PMREM async tests retargeted to the r187 cube-camera generator.
- Patch: ShadowFilterNode keeps the stock trailing newline (the hunk was a no-op).
- Scaffold hashes recomputed for the new patch bytes.
…haviours the rebase lost (PRD-775)

Stubs: renderer/backend/builder stubs gain the r187 members they now need.
Patch (from arm E's findings, each with its failing spec):
- WebGPUBackend._validateTransientDepth accepts r187's read-only 'let VERTEX_nodeConst' vertex chain
  (private depth never validated: 38 tests).
- PRD-566 morph-layout term restored in the WebGPU pipeline key (getGeometryCacheKey( false )).
- NodeMaterialObserver asks firstInitialization before the settled recompute registers the object.
- Geometries.updateAttribute re-uploads a buffer written again within one render call.
Engine: GpuFrameObservation detects r187's self-resolving full query pool (index restart).
@jonit-dev jonit-dev added prd:25% PRD phase 1 verified and removed prd:0% PRD implementation has no verified phase yet labels Oct 31, 2026
….0 (PRD-775)

95 of 205 goldens changed, each with an upstream reason (PR body): r187 ACES fit, r186/r187 physical
lighting energy rewrite, r187 PMREM cube targets, r187 TRAA, r187 LatheGeometry normal.
The owned engine's ACES fit follows r187 (output ramp worst 0/255). Native pins, catalog and ABI header
name 0.187.0; the reference page serves addons from the pinned tree by path.
…on the native host (PRD-775)

r187's WebGPUBackend asks 'currentPass instanceof GPURenderBundleEncoder' on every draw; the native host
never defined the class, so the native smoke died with a ReferenceError (pump-silence 2 red -> 13/13).
The class answers by a brand createRenderBundleEncoder puts on its encoders. Spec pins name 0.187.0.
…n three 0.187.0 (PRD-775)

r187 frees a node-owned storage attribute on its own dispose event, no longer through the geometry's
render objects; the probe's five instancedArray buffers stayed live after detach (spec red -> 166/166).
The spec follows r187's Geometries(backend, attributes, info) and initGeometry(geometry).
…(PRD-775)

Ports r186/r187 PhysicalLightingModel: one shared DFG sample, multiScatteringCompensation on direct
specular, (1 - F) on direct diffuse, the dielectric energy loss on indirect diffuse, BRDF_GGX's 0.045
alpha floor and getRoughness's 0.045 floor, and clearcoatRoughnessMap reading green. BRDF_GGX_Multiscatter
is gone as upstream. lit_reference worst 9 -> 1 (13.1% -> 0% channels over one level); 74/74 renderer cases.
…hree 0.187.0 (PRD-775)

r187's NodeBuilder skips the shared bind-group cache only when renderer._bindings is undefined, but an
uninitialised Renderer holds null there; building a node before init crashed (8 spec files). The patch
checks != null. temporal-aa's builder stub answers r187's getNodeFromHash.
… import and references (PRD-775)

- Ray.intersectTriangle is r187's watertight test (Ray.js:538-662); raycaster 9 differ -> 0.
- LatheGeometry normalizes the last point's normal (r187 LatheGeometry.js:106).
- The graph importer no longer lowers a void Fn loop body twice: r187 dropped VarNode intents.
- The TSL corpus harness reads r187's Fn()() and assignable shapes; corpus text byte-identical.
- References regenerated from 0.187.0; post tests name r187. Lockfile carries the new patch hash.
native-engine label: 33 -> 8 failures (all render/post work in progress).
…ee 0.187.0 (PRD-775)

- TRAA: jitter wraps at 32 (r187 TRAANode.js:330); the history seeds from the resolved frame's own beauty
  (r187 :390-400), not a pre-scene copy. traa_history 0.928 -> pass, history_cut 0.968 -> pass.
- Shadows: r187 removed PCFSoftShadowFilter and rewrites PCFSoftShadowMap to PCFShadowMap
  (Renderer.js:1031); radiusScaled is per-axis. shadows-directional-soft 0.080 -> pass.
- MaterialX: mx_hash_int_3 applies mx_bjmix as r187 emits it. materialx-noise 0.404 -> pass.
- frame_slab light-flip fingerprint 2037 -> 2034 after the r187 lighting port.
…specs follow three 0.187.0 (PRD-775)

r187 TRAANode hands its view-offset pair to the pipeline through OnBefore/OnAfterRenderPipeline lists and
only while renderPipelineState.viewOffsetOwner is free; the starter's provider now claims that owner and
removes only its own entries. Specs: real setup-stage builders, r187 renderer.debug, compute via
builder.compute, ReferenceBufferAttributeNode, cached size nodes. 10 spec files red -> green.
…ropping their frame (PRD-775)

The patch's queue-fenced PMREM bake binds PMREMNode's placeholder CubeTexture while the bake runs.
Textures.getSize gave an image-less cube texture one layer, the cube view asked for six, and Dawn
dropped the whole command buffer: every environment-lit scene drew black until the bake landed, and
six 0.187.0 goldens were recorded black. getSize now allocates 1x1x6 (as _getDefaultCubeTextureGPU
does), and Renderer.whenEnvironmentReady() lets a single-frame caller draw the settled look.

The reference capture now fails closed on a WebGPU validation error (TN_FIXTURE_RENDER_GPU_ERROR) and on
a one-colour frame (TN_FIXTURE_RENDER_UNIFORM_FRAME). Re-recorded only the six goldens; the
pmrem-hdr-equirect frame is byte-identical to stock r187. test:reference --check: 205 fixtures, 0 problems.
… shipped sRGB exponent (PRD-775)

GTAO: r187 GTAONode.js:405-584 (projectionDepth, background discard, integer STEPS, clip-space quadratic
march, IGN+rand jitter, new horizon update, isValidSample); the engine's reachPx Unreal search stays
verbatim. Bloom: r187 BloomNode.js:519-531 merges adjacent Gaussian taps into one bilinear fetch.
Output: three's build ships pow(0.41666), not 1/2.4 (three.tsl.js:9382); the engine now matches it.
tsl-post-ao-raw, tsl-post-bloom (0 px), live-parameters, template-high and reflector-plane (0 px) pass;
gtao_screen_reach's world-radius control arm re-measured. native-engine: 523/526.
…noiseNode (PRD-775)

three inlines DenoiseNode into its consumer (r187 DenoiseNode.js:16,163-259), so the bilateral result is
never stored as half float. The engine stored every post pass as RGBA16F; on a flat AO field the f16 rounding
is one-sided and flipped 11.5% of tsl-post-ao one 8-bit level down (dE 0.060 vs gate 0.04). The denoise pass
now targets RGBA32Float when float32-filterable exists (RGBA16F fallback otherwise). tsl-post-ao dE 0.0044.
native-engine: 524/526; left: render_pmrem, render_data_textures (native PMREM port in flight).
…D-775)

three 0.187 replaced the cubeUV atlas with a cube render target whose mips
carry the roughness steps, so the engine's generator and sampler move
together (report-K's change list, items 1-6 and 8):

- the generator bakes a HalfFloat source cube from the equirect with four
  subpixel taps, grows its full mip chain, then filters mip lod at
  lodToRoughness(lod, maxLod): 256-sample GGX VNDF with filtered importance
  sampling and the per-lod lodBias for the sharp levels, and the 16px
  integration tail over three faces for the last three (MIN_SIZE 256,
  LOD_MIN 3, so a 64x32 sky bakes a 256 cube with six mips where r185 made
  a 16px atlas).
- every pass draws one face of one mip of the cube: the equirect conversion
  and the bake read the direction through CubeCamera's view matrix, so the
  cubes they write are the cubes three writes, and the source mip chain
  downsamples in the row order a face is stored in.
- the sampler's PMREM is a cube texture again: roughnessToMip = maxLod*r*(2-r)
  replaces the piecewise cubeUV ramp, textureCubeUV and its 16px filter tiles
  are gone, and the render-target Y flip goes with the atlas.
- frame-pumped packets, the environment validation, the pmremRotation and
  environmentRotation wiring are unchanged; one packet is one face now, so
  the budget counts fragments x samples per face.

Every environment fixture matches three 0.187.0: native_engine_render_pmrem,
_render_data_textures, and the hand-run sky-equirect, material-ao-environment,
pbr-grid-envmap and pmrem-texture cases.
…case (PRD-775)

sky-equirect, material-ao-environment and pbr-grid-envmap light a scene from
three's PMREM, and no ctest case ran them, so the r185-to-r187 PMREM port had
no gate on the two of them that sample an environment through a material.
One case, native_engine_render_environment, runs all three against their
0.187.0 goldens. Comments that still described the PMREM as a cubeUV atlas
follow the code.
…urce (PRD-775)

three reads the PMREM target and the source cube through the same
LinearMipmapLinearFilter clamp sampler, so the bake needs one cube sampler
plus the 2D one the mip chain blits with.
@jonit-dev

Copy link
Copy Markdown
Contributor Author

PRD-775 ARM A — three.js patch port 0.185.1 → 0.187.0

Files: the 15 in this arm's list. Every hunk of git diff base patched -- <file> is one row, in patch order.
Marker = literal in the final file that is absent from pack/r187/package/<file> (verified with grep -F).


src/materials/nodes/manager/NodeMaterialObserver.js

# intent (one line) origin (sha / PRD) decision reason marker
1 flag on the observer while the current render produces motion vectors 3630847 / f1040a1 (PRD-269, PRD-455) kept renderingVelocity field merged clean; r187 has no equivalent (it recomputes needsVelocity() inline and would lose the value updateSettled reads). this.renderingVelocity = false;
2 isSettled/updateSettled: per-draw "static object and nothing it froze changed" answer, so OBJECT updates, the object UBO upload and the attribute scan can be skipped 3630847 / f1040a1 (PRD-269) ported Kept both methods; they still read matrixWorld, material.version, geometry.version and the scene environment, all unchanged in r187. r187 has nothing equivalent (its skip is object.static === true, which is the bug this fixes: a never-drawn static object would skip the draw that creates its bind groups). updateSettled( renderObject ) {
3 drop _equal from getGeometryData 3630847 (PRD-269) dropped-upstream r187 replaced the flag with a counter: _version: 0 in the same literal (src/materials/nodes/manager/NodeMaterialObserver.js:420 r187) plus geometry.addEventListener('dispose', () => data._version ++). -
4 drop _equal from getMaterialData 3630847 (PRD-269) dropped-upstream r187 r187:536 data = { _renderId: - 1, _version: 0 }. -
5 drop materialData._equal = false before a material diff return 3630847 (PRD-269) dropped-upstream r187 r187:604-686 rewrote the block around changed = true / materialData._version ++, which is this fix done properly. -
6 same, vector-valued material property branch 3630847 (PRD-269) dropped-upstream r187 changed = true in the value.equals branch. -
7 same, texture-valued material property branch 3630847 (PRD-269) dropped-upstream r187 changed = true + value.cacheVersion/value.transform tracking. -
8 same, scalar material property branch 3630847 (PRD-269) dropped-upstream r187 changed = true in the else branch. -
9 drop the _equal = true / else if _equal === false early-out so every render object re-answers for itself 3630847 (PRD-269) dropped-upstream r187 r187:684-694 replaced it with the per-render-object renderObjectData.materialVersion !== materialData._version mismatch, plus an output-target version check. -
10 drop geometryData._equal = false, removed-attribute branch 3630847 (PRD-269) dropped-upstream r187 changed = true; continue;. -
11 same, changed-attribute branch 3630847 (PRD-269) dropped-upstream r187 changed = true. -
12 same, attribute-set changed branch 3630847 (PRD-269) dropped-upstream r187 changed = true. -
13 same, index changed branch 3630847 (PRD-269) dropped-upstream r187 changed = true. -
14 same, draw-range changed branch 3630847 (PRD-269) dropped-upstream r187 changed = true. -
15 drop the geometry _equal = true / else if early-out 3630847 (PRD-269) dropped-upstream r187 r187:805-820 if ( changed === true ) geometryData._version ++; + renderObjectData.geometryVersion !== geometryData._version. -
16 publish the settled answer for the draw, then use it instead of needsVelocity() 3630847 (PRD-269) ported Same two statements, but the early-out now returns RenderObjectRefreshType.FULL (r187's needsRefresh returns an enum, not a boolean) and keeps r187's new hasDynamicInstancing() term. this.renderingVelocity = this.needsVelocity( nodeFrame.renderer );
17 replace the object.static early-out with isSettled() so only settled objects skip 3630847 (PRD-269) ported if ( this.isSettled( renderObject ) === true || isBundle ) ; the body returns refreshType instead of false, so r187's target-switch / disposed-attribute / shared-UBO refresh verdicts still win. const isStatic local removed (now unused). if ( this.isSettled( renderObject ) === true || isBundle )

src/nodes/accessors/Batch.js

# intent (one line) origin (sha / PRD) decision reason marker
1 import positionPrevious for the motion-vector batch transform c03b929 dropped-upstream r187 src/nodes/accessors/Batch.js:2 imports { positionLocal, positionPrevious } already. -
2 previousMatricesTexture = batchMesh._previousMatricesTexture ?? matricesTexture c03b929 dropped-upstream r187 r187:88-108 getPreviousMatricesTexture( batchMesh ) creates the texture from _matricesTexture on first use (and again after BatchedMesh disposed it), which is the same fallback with a lifecycle. -
3 assign positionPrevious from the previous batching matrix when needsPreviousData() c03b929 dropped-upstream r187 r187:146-167 does exactly this: createBatchingMatrixNode( getPreviousMatricesTexture( batchMesh ), indirectId ) → positionPrevious.assign( previousBatchingMatrixNode.mul( positionPrevious ).xyz ), plus the OnAfterObjectUpdate copy of the matrices texture that our hunk did not have. -
4 drop the two trailing blank lines at EOF c03b929 dropped-upstream cosmetic only; r187 keeps them. Nothing to port. -

File now equals stock r187. src/objects/BatchedMesh.js was checked for the duplicated half: r187 owns _previousMatricesTexture creation/dispose (r187:294, 1342-1345, 1562-1565) and Batch.js only fills it, so there is no double owner.


src/nodes/accessors/Instance.js

# intent (one line) origin (sha / PRD) decision reason marker
1 import the before-object event and drop OnFrameUpdate 3630847 (PRD-269) ported r187 needs OnAfterObjectUpdate (its own fallback snapshot) instead of OnObjectUpdate, plus OnBeforeObjectUpdate for the scheduled history: import { OnAfterObjectUpdate, OnBeforeObjectUpdate }. import { OnAfterObjectUpdate, OnBeforeObjectUpdate } from '../utils/EventNode.js';
2 force the vertex-attribute path in createInstanceMatrixNode (the uniform path re-uploads every draw) 4f9638c (PRD-453-457) kept r187 still has the same uniformBufferSize <= builder.getUniformBufferLimit() branch (r187:44-52), so the same if ( false ) keeps the cheaper path. if ( false ) {
3 force the interleaved-attribute path in instance() and track the buffer 4f9638c (PRD-453-457) dropped-obsolete r187 deleted the whole interleaved tracking: no _matrixBuffers, no interleavedMatrix/interleavedColor locals; the columns are new InterleavedBufferAttribute( instanceMatrix, 4, offset ) bound straight to the attribute, and the buffer is freed by instanceMatrix.dispose(). Nothing left to force. -
4 sync the interleaved buffers on OnBeforeFrameUpdate 4f9638c (PRD-453-457) dropped-obsolete The block this changed does not exist in r187 (same deletions as row 3); r187 uploads on attribute version change instead. -
5 scheduled previous instance matrices (threenative.velocity.previousInstanceMatrices) must land before the geometry upload, else keep Three's after-draw snapshot f1040a1 (PRD-269/455), 2d21247 ported r187 moved the snapshot to OnAfterObjectUpdate for both paths, so the port adds a OnBeforeObjectUpdate pre-pass and guards the after-pass for both: the instance() path (_previousInstanceMatrices) and the ReferenceAttributeNode path (getPreviousMatrix). needsUpdate = true is used instead of the removed interleaved-buffer needsUpdate bookkeeping. threenative.velocity.previousInstanceMatrices

src/nodes/accessors/Skinning.js

# intent (one line) origin (sha / PRD) decision reason marker
1 previousBoneMatricesData() — per-skeleton history created at the current pose so the first frame has zero skin motion c93b427 (PRD-723/746/747/748/749/752) dropped-upstream r187 setupPreviousBoneData( skeleton ) (r187:159-187) does the same, storing on the skeleton (previousBoneMatrices, previousBoneTexture, both declared in src/objects/Skeleton.js:80,98) instead of a module WeakMap. -
2 resolve the bone history per drawn object, and use that frame's bind inverse c93b427 + f1040a1 (PRD-269) ported (split) History half: dropped-upstream — r187 resolves it per object already (referenceBuffer( 'skeleton.previousBoneMatrices' ), and the texture variant's OnObjectUpdate sets object.skeleton.previousBoneTexture). Bind-inverse half: ported — nothing in r187 reads a scheduled bind inverse, so previousBindMatrixInverse (uniform, onObjectUpdate, threenative.velocity.previousBindMatrixInverse ?? mesh.bindMatrixInverse) is added to both the skinning() and computeSkinning() previous-position paths. const previousBindMatrixInverse = uniform( skinnedMesh.bindMatrixInverse
3 let a scheduler supply the previous bone matrices in skinning() c93b427 ported r187:255 writes skeleton.previousBoneMatrices.set( skeleton.boneMatrices ); now scheduledPrevious ?? skeleton.boneMatrices, reading the symbol off the drawn object inside OnObjectUpdate. threenative.velocity.previousBoneMatrices
4 same in computeSkinning() c93b427 ported r187:327 same pattern; the symbol is read off skinnedMesh because that path is not per object. threenative.velocity.previousBoneMatrices

src/nodes/accessors/TextureNode.js

# intent (one line) origin (sha / PRD) decision reason marker
1 pass isRawTextureNode into generateTextureLoad so the backend can pick the raw (sample-index) load form f1040a1 (PRD-269/455) kept r187's generateTextureLoad still takes 6 params, so the extra argument is inert but harmless and keeps the feature wired for the backend half of the patch. Hunk merged clean and the surrounding sampler === false branch is unchanged. this.isRawTextureNode === true );

src/nodes/accessors/TextureSizeNode.js

# intent (one line) origin (sha / PRD) decision reason marker
1 a raw pass attachment is a multisampled texture, so textureDimensions() must not be given a mip level f1040a1 (PRD-269/455) ported r187 moved the snippet into builder.generateTextureSize(), which decides from getTextureSampleData(); that returns primarySamples === 1 for a render-target texture (r187 resolves MSAA into a single-sample texture, WebGPUTextureUtils isMSAA → separate -msaa texture), so it would emit the level. The raw branch is emitted before the delegation, same intent as 0.185. if ( this.textureNode.isRawTextureNode === true ) {

src/nodes/accessors/VelocityNode.js

# intent (one line) origin (sha / PRD) decision reason marker
1 prefer the scheduler's previous world matrix over Three's own snapshot 3630847 (PRD-269) kept Hunk merged clean inside update(); r187 still resolves the reference from the per-object frame ({ frameId, camera, object }), so object[ Symbol.for( ... ) ] ?? previousModelMatrix applies unchanged. threenative.velocity.previousWorldMatrix

src/nodes/core/NodeBuilder.js

# intent (one line) origin (sha / PRD) decision reason marker
1 needsPreviousData() must also honour object.userData.useVelocity (how ThreeNative switches velocity on per object) c03b929 ported r187 deleted the getDataFromObject( this.object ).useVelocity field entirely (useVelocity no longer exists in r187 src/), so only the userData clause survives: ( mrt !== null && mrt.has( 'velocity' ) ) || this.object.userData?.useVelocity === true. Dropping getDataFromObject also matches r187 dropping its import. this.object.userData?.useVelocity === true

src/nodes/display/PassNode.js

# intent (one line) origin (sha / PRD) decision reason marker
1 PassMultipleTextureNode takes a rawTexture flag f1040a1 (PRD-269/455) kept Constructor merged clean onto r187's PassTextureNode base (super( passNode, null ) still correct). this.isRawTextureNode = rawTexture;
2 mark the node raw and force setSampler( false ) f1040a1 kept setSampler still exists in r187 TextureNode (r187:777). this.isRawTextureNode = rawTexture;
3 unique uniform hash per raw attachment, plus setup() preconditions and the clone() passthrough f1040a1 kept super.getUniformHash( builder ) and builder.renderer.backend.isWebGPUBackend both still exist in r187; the r187 rewrite of PassNode (now extends Node) does not touch these members. :raw4:
4 _rawTextureNodes / _rawTexturesDisposed fields f1040a1 kept Merged clean; the new fields sit beside r187's untouched _textureNodes. this._rawTexturesDisposed = false;
5 refresh the raw node on toggleTexture() f1040a1 kept toggleTexture() is unchanged in r187 PassNode. raw4:
6 getRawTextureNode( name ) accessor f1040a1 kept Merged clean. Moved the (previously stolen) getTextureNode JSDoc back onto getTextureNode and gave getRawTextureNode its own. getRawTextureNode( name = 'output' ) {
7 mark the pass disposed so later raw lookups throw f1040a1 kept r187 added super.dispose() to PassNode.dispose(); the flag is set after it, order is irrelevant. _rawTexturesDisposed = true;

src/nodes/lighting/PointShadowNode.js

# intent (one line) origin (sha / PRD) decision reason marker
1 share the shadow uniforms instead of building them per material afee25e (PRD-494) ported r187 exports nothing here; the helper that now exists upstream is getShadowUniforms( shadow ) in ShadowFilterNode.js. The port imports that name instead of our getShadowUniform. The now-unused uniform / reference / renderGroup imports were dropped. bias } = getShadowUniforms( shadow );
2 PointShadowFilter: one radius / mapSize node per shadow (r187 builds five uniforms per material here) afee25e (PRD-494) ported const { radius, mapSize, near, far, bias } = getShadowUniforms( shadow ); — r187's own body created all five as fresh nodes per builder, exactly the buffer-per-material problem the hunk fixes. bias } = getShadowUniforms( shadow );
3 pointShadowFilter: one cameraNear / cameraFar / bias node per shadow afee25e (PRD-494) ported const { near: shadowCameraNear, far: shadowCameraFar, bias } = getShadowUniforms( shadow );; the uniform( 'float' ).onRenderUpdate( () => shadow.camera.near ) bodies moved into the shared helper unchanged. far: shadowCameraFar

src/nodes/lighting/ShadowFilterNode.js

# intent (one line) origin (sha / PRD) decision reason marker
1 one uniform node per shadow (module WeakMap + getShadowUniform( shadow, name, create )) so every material shares the render-group buffer afee25e (PRD-494) ported Upstream added the same idea as getShadowUniforms( shadow ) (r187:13-37) but only for mapSize/radius, and left the point-shadow uniforms per material. Kept upstream's shape, added near/far/bias to it and exported it, so there is exactly one mechanism. export function getShadowUniforms( shadow ) {
2 PCFShadowFilter uses the shared mapSize / radius afee25e (PRD-494) dropped-upstream r187:85-86 const { mapSize, radius } = getShadowUniforms( shadow );. -
3 PCFSoftShadowFilter uses the shared mapSize afee25e (PRD-494) dropped-obsolete PCFSoftShadowFilter was deleted upstream (r187 src/nodes/lighting/ShadowNode.js:117 keeps null /* PCFSoftShadowMap, removed */ in _shadowFilterLib; no references remain in src/). Nothing left to fix. -

src/nodes/pmrem/PMREMNode.js

# intent (one line) origin (sha / PRD) decision reason marker
1 module _pending / _failed maps and a _listenForDispose helper for the PMREM cache 4e8fe84 (PRD-624/573), 2d21247 ported Kept, with r187's invalidation folded in: the cache now stores the CubeRenderTarget, so the listener sets pmrem.texture.pmremVersion = - 1 before pmrem.dispose() (r187:73-78). const _pending = new WeakMap();
2 while a preparation is pending, never start a second bake for the same texture 4e8fe84 ported pending.textures.has( texture ) guard kept; the cached value is now a render target, so it compares/returns prepared.texture.pmremVersion and prepared.texture. prepared.texture.pmremVersion === texture.pmremVersion
3 on a queue-fenced backend never bake synchronously (all GGX passes in one submission hang the GPU process) 4e8fe84 ported renderer.backend.device.queue.onSubmittedWorkDone is unchanged in r187; the fire-and-forget prepareTextureAsync + _failed guard are kept as-is. fence.onSubmittedWorkDone
4 use _listenForDispose instead of the inline listener 4e8fe84 ported Inline block replaced by the helper (which keeps r187's pmrem.texture.pmremVersion = - 1). _listenForDispose( texture, cache );
5 PMREMNode.prepareTextureAsync() — serialized, per-renderer queue of background bakes 4e8fe84 ported Rewritten for r187: cache values are CubeRenderTargets (generated.texture.pmremVersion = version, generated.dispose()), and the CubeUVReflectionMapping early-out became r187's isPMREMTexture === true (the constant is no longer imported by this file). fromCubemapAsync/fromEquirectangularAsync still exist in r187 PMREMGenerator (deprecated, await renderer.init() then _fromTexture, returning a CubeRenderTarget). static async prepareTextureAsync( renderer, texture ) {
6 Y flip only for generated PMREMs, read off the live texture (this._texture.value.isRenderTargetTexture) f1040a1 dropped-obsolete The whole Y-flip block and textureCubeUV/_width/_height/_maxMip were removed upstream; r187 samples the generated cube texture directly (this._texture.sample( materialEnvRotation.mul( uvNode ) ).level( roughnessToMip( levelNode, this._maxLod ) )). The stale this._pmrem read the hunk fixed no longer exists in setup(). -

src/objects/BatchedMesh.js

# intent (one line) origin (sha / PRD) decision reason marker
1 capability marker so the engine can detect the batched-velocity patch at runtime c03b929 kept Independent of upstream's work; _previousMatricesTexture now exists in r187 itself, but the flag is what readPatchedThree/hasBatchedVelocityPatch probes for. threeNativeBatchedVelocityPatch

examples/jsm/tsl/display/SMAANode.js

# intent (one line) origin (sha / PRD) decision reason marker
1 pin the edges texture to mip 0 so the bounded searches contain no implicit derivatives (FXC) 4e8fe84 (PRD-624/573) kept texture( ... ).level( 0 ) is unchanged API in r187; hunk merged clean, TextureNode.level() still exists. texture( this._renderTargetEdges.texture ).level( 0 )

examples/jsm/tsl/display/SSRNode.js

# intent (one line) origin (sha / PRD) decision reason marker
1 maxSteps uniform capping the mirror trace's raymarch steps 37fccbc (PRD-732/733/734/735) kept Merged clean; the field sits after r187's quality uniform. this.maxSteps = uniform( 1e6 );
2 apply the cap to totalStep 37fccbc (PRD-732/733/734/735) kept Merged clean onto r187's trunc( max( abs( xLen ), abs( yLen ) ).mul( this.quality.clamp() ) ) chain. .min( this.maxSteps )

Doubts, ranked by risk

  1. TextureSizeNode + TextureNode only half-wire the raw pass attachment. r187's WGSL backend takes 6 args in generateTextureLoad and declares a texture's binding type from getTextureSampleData(), which reports primarySamples === 1 for a render-target texture because r187 resolves MSAA into a separate -msaa texture. On the 0.185 patch the raw binding was forced multisampled in WGSLNodeBuilder (primarySamples > 1 || textureNode.isRawTextureNode === true). If the backend arm ports that, the level-less textureDimensions() I emit is right; if it does not, isRawTextureNode is dead and both hunks are inert but harmless (only our own PassNode ever sets the flag). Must be settled against the WGSLNodeBuilder.js / WebGPUBindingUtils.js arms before trusting raw pass reads on r187.
  2. Batch.js now has no patch content at all. packages/core/__tests__/packaging.spec.ts:275 asserts the packed build/three.webgpu.js contains _previousMatricesTexture ?? matricesTexture, and packaging.spec.ts:182 asserts it in three@0.185.1.patch. Both will fail when the 0.187 patch is generated; the string lives only in the old Batch.js hunk. Needs an engine-repo test update (out of this arm's scope).
  3. needsPreviousData() lost getDataFromObject( this.object ).useVelocity. r187 deleted that field, and the engine only ever sets object.userData.useVelocity (packages/core/src/render/velocity.ts:259), so the port is correct — but any ThreeNative code or persisted scene data that relied on the old data-map flag would silently stop requesting previous data.
  4. Instance.js version bookkeeping is now mixed. The scheduler path uses needsUpdate = true (version++), the fallback keeps r187's previous.version = matrices.version. If a scheduler stops writing history mid-run, the fallback can assign a version that is not greater than the one the scheduled write set; worst case is one redundant re-upload or one delayed upload, and no GPU was available to confirm.
  5. getShadowUniforms() now eagerly creates near/far/bias for every PCF shadow (directional/spot included) even though only point shadows build them. Unused nodes are never built into a shader, so this is allocation only — but it does mean the helper is no longer a pure mapSize/radius carrier, i.e. a future upstream change to that helper will conflict.
  6. updateSettled() does not know about output-target switches or disposed attributes. A settled object on a resized/retargeted pass still refreshes because r187's pre-checks set refreshType = FULL before the isSettled() early-out — but that is now an invariant on r187's needsRefresh() body, not on isSettled(). If a later upstream change moves those checks below the early-out, settling would skip a required refresh.
  7. PCFSoftShadowFilter is gone upstream (ShadowNode.js:117 keeps a null slot). Any ThreeNative material that still sets shadowMap.type = PCFSoftShadowMap now silently falls back to the removed-filter slot behaviour rather than to our de-duplicated soft filter.
  8. SSRNode's maxSteps default of 1e6 is unchanged, so the mirror trace is still effectively unbounded unless a caller sets it — the port preserves the stock behaviour, per the patch's own comment.
  9. SMAANode: the patch keeps its // comment between the JSDoc block and the this._edgesTextureUniform assignment; harmless for tooling but not idiomatic three.js.

ARM DONE

@jonit-dev

Copy link
Copy Markdown
Contributor Author

Ledger — ARM B (0.185.1 → 0.187.0 port of the ThreeNative patch)

Repo: ~/.cache/tmp-tn/prd775/merge, branch rebased. Stock reference tree: ~/.cache/tmp-tn/prd775/pack/r187/package/.
Origins read from the read-only engine repo (packages/core/patches/, 4e8fe84ae squashes PRD-624, so the
immediately preceding wip commit is cited where it is more specific).

Notes that apply to every row:

  • git diff base patched lists the ThreeNative hunks; git diff r187 -- <file> is my result against stock.
  • Where a ThreeNative hunk and an upstream r187 change are the same fix, the row says dropped-upstream
    and cites the r187 lines; where the shape of the 0.185.1 code the hunk patched no longer exists, it says
    dropped-obsolete and proves it.
  • Renderer.js hunk 13/14 are dropped-upstream (see the note under that table) — upstream's
    RenderObjectRefreshType machinery answers the same question our "always run every step" answered, and the
    isSettled gate the observer arm ported keeps it honest.

src/renderers/common/Bindings.js

# intent (one line) origin (sha / PRD) decision reason marker
1 Tell the binding update whether the render object is settled, so per-object uniform groups can skip their write a602467 (PRD-458/473) kept updateForRender still forwards to _updateBindings; r187 kept that call site unchanged, so only the extra argument was needed this.nodes.isSettled( renderObject )
2 Refresh a sampled-texture / sampler binding before the first GPU bind group is created, so an async warmup that yielded while the node swapped targets does not bake a stale reference 7870147 (fix(core): refresh initial texture bindings after async warmup) ported r187 changed both lines to binding.generation = this.textures.updateTexture( … ) / binding.samplerKey = this.textures.updateSampler( … ); kept that return-value form and added binding.update() in front of each // Refresh its reference before creating the first GPU binding, not after.
3 Thread settled through _updateBindings / _update a602467 (PRD-458/473) kept Same call graph in r187 (updateForCompute still calls _updateBindings with one argument → undefined, i.e. not settled) @param {boolean} [settled] - Whether the render object is a settled static object.
4 Build a bind group that a node added after the object's first draw, on every draw, and force its values to be written a602467 (PRD-458/473) kept r187 has no equivalent (its _createBindings only runs for uninitialised render objects); harmless when the refresh-type gate skips the call // A group a node added after this object's first draw has no buffers and no layout yet, and
5 A settled object still has to notice that the GPU texture behind one of its bindings was re-created (re-sized render target) 4e8fe84 / f30f9b6 (PRD-624, PRD-478) kept Compare binding.generation with this.textures.get( texture ).generation; r187 keeps textureData.generation and the binding.generation field, so the comparison is still meaningful if ( settled === true && binding.isSampledTexture === true && binding.texture !== undefined )

src/renderers/common/Geometries.js

# intent (one line) origin (sha / PRD) decision reason marker
1 Remember the attribute versions a settled object was last drawn with a602467 (PRD-458/473) kept updateForRender is still the per-draw hook in r187; the WeakMap has no upstream equivalent this._settledAttributes = new WeakMap();
2 Skip the attribute scan for a settled object only when no attribute version moved; add attributesChanged() a602467 (PRD-458/473) kept renderObject.getMonitor() (RenderObject.js:482) and monitor.isSettled() (observer, ported by another arm) exist in the merged tree. r187's monitor also compares instance/attribute versions in equals() — that is the same signal one layer up, so this stays as the second line of defence and is inert, not wrong, when the refresh type is FULL/SHARED attributesChanged( renderObject ) {

src/renderers/common/RenderContexts.js

# intent (one line) origin (sha / PRD) decision reason marker
1 The default render target (canvas) must report the renderer's current sample count, not "1", so output-pass and direct-overlay passes do not share a render state 2d21247 (PRDs 466–468) / 1abe8f3 kept Overwrites the sampleCount line r187 still has verbatim at RenderContexts.js:89; renderer.currentSamples exists at Renderer.js:2962 const sampleCount = renderTarget !== null ? renderTarget.samples : this.renderer.currentSamples;

src/renderers/common/RenderObject.js

# intent (one line) origin (sha / PRD) decision reason marker
1 Track the version of _sourceMaterial this render object was validated against (override material is a shared scratchpad) f30f9b6 (PRD-478) dropped-upstream r187 added the same field for the same reason: this.sourceMaterialVersion = this._sourceMaterial !== null ? this._sourceMaterial.version : 0; (RenderObject.js constructor, and RenderObjects.get compares it). Our sourceVersion was deleted so only one name exists -
2 getChainArray() must include the source material so override and non-override draws of the same object are different chains f30f9b6 (PRD-478) dropped-upstream r187 returns [ object, material, context, lightsNode, this._sourceMaterial ] when _sourceMaterial !== null, which is the same discriminator (ours pushed _sourceMaterial ?? material, which duplicated element 1 when there was no override) -
3 getGeometryCacheKey( useMorphTargetIds ) — a pipeline key must not depend on morph-attribute object identity, only on their layout a09cea3 (PRD-566) dropped-upstream r187 added WebGPUAttributeUtils.getVertexBufferLayoutCacheKey( renderObject ) to the pipeline cache key and removed stride/offset/index from the geometry key, i.e. the pipeline key is now built from the real vertex layout (arrayStride/stepMode/shaderLocation/offset/format), which is strictly more precise. No caller passes false any more, so the parameter (and its morphTargetsRelative branch) was removed to avoid dead code -
4 The dynamic cache key must include the render context's sample count, so a 4× and a 1× pass do not share cached render objects 32ebfa8 (PRD-400) ported r187 rewrote getDynamicCacheKey() to hashArray( _cacheKeyValues ) with a fixed 5-slot array; grew the array to 6 and set slot 5 to this.context.sampleCount (RenderContext.sampleCount exists, RenderContext.js:212) _cacheKeyValues[ 5 ] = this.context.sampleCount;

src/renderers/common/RenderObjects.js

# intent (one line) origin (sha / PRD) decision reason marker
1 Chain key must include the source material (_chainKeys[ 4 ]) so material groups sharing a shadow override keep separate GPU bindings f30f9b6 (PRD-478) dropped-upstream r187 introduced _overrideChainKeys (5 slots, [ null, null, null, null, null ]) used only while an override is active, with chainKeys[ 4 ] = sourceMaterial; that is the same fix, and it avoids the stale 5th slot in the non-override array -
2 Version gate must answer with the source material's version while drawing through an override material, and rebuild when the source material was replaced f30f9b6 (PRD-478) dropped-upstream r187's gate is renderObject.version !== material.version || renderObject.sourceMaterialVersion !== sourceMaterialVersion || renderObject.needsUpdate || force. sourceReplaced cannot fire in either tree because the source material is part of the chain key, and force (geometry cache-key change) is upstream's extra. Dropped our sourceVersion/sourceChanged block -
3 Reset _chainKeys[ 4 ] after the lookup f30f9b6 (PRD-478) dropped-upstream r187 resets chainKeys[ 4 ] only when sourceMaterial !== null, which matches its 4-vs-5 slot scheme -

Result: this file is byte-identical to stock r187 (diff -q clean).


src/renderers/common/RenderPipeline.js

# intent (one line) origin (sha / PRD) decision reason marker
1 The pipeline quad is the renderer's output owner; restore renderer state (xr, tone mapping, colour space, owner) even if the draw throws f30f9b6 (PRD-478) / 1d056ce kept r187 kept the un-guarded sequence at RenderPipeline.js:146-160; our try/finally wraps only _quadMesh.render( renderer ), the trailing upstream restores stay (harmless duplicates on the success path, the safety net on the throw path) const previousOwner = renderer._tnOutputOwner;

src/renderers/common/Renderer.js

# intent (one line) origin (sha / PRD) decision reason marker
1 Mark which scene owns the render context's output (transient depth may only be speculated for it) 1d056ce (#486) kept r187 has no _tnOutputOwner; the WebGPU backend's _beginTransientDepth / _validateTransientDepth read it renderContext._tnOutputOwner = scene === this._tnOutputOwner ? scene : null;
2 renderScene must finish or abort the pass even when an object draw throws, and always restore the render tree f30f9b6 (PRD-478) kept r187 still calls backend.finishRender unconditionally after the render lists; the nested try/finally adds the abort path (_abortTransientDepth) the transient-depth feature needs else if ( this.backend.get( renderContext )._tnDepth ) this.backend._abortTransientDepth( renderContext );
3 (same hunk as 2 — the tree restore / _callDepth -- moved inside the inner finally) f30f9b6 (PRD-478) kept as row 2 renderContext._tnOutputOwner = scene === this._tnOutputOwner ? scene : null;
4 Public deleteAttribute( attribute ) so an exclusively owned attribute is released through backend + memory accounting f1040a1 (PRDs 269/455) kept Attributes.delete( attribute ) exists at Attributes.js:75 in r187 deleteAttribute( attribute ) {
5 Cold, explicit async compressed-texture gate (prepareTextureAsync) 2d21247 / 4e8fe84 (PRD-624) kept Calls this._textures.updateTextureAsync, which row Textures#7 provides; the WebGPU backend exposes updateTextureAsync async prepareTextureAsync( texture, options = {} ) {
6 Cache the shadow pass's alphaTestNode per material (shadow nodes cache keyed by material) f30f9b6 (PRD-478) kept NodeMaterial.alphaTestNode exists (NodeMaterial.js:221) and r187's getShadowNodes cache object has no such entry alphaTestNode: material.alphaTestNode && material.alphaTestNode.isNode ? material.alphaTestNode : null
7 Save/restore the override material's alphaTestNode around the override block f30f9b6 (PRD-478) kept Same override-save pattern as the surrounding upstream lines let materialAlphaTestNode;
8 Capture the caster's alphaTest and alphaTestNode before mutating the shared override material f30f9b6 (PRD-478) kept r187 still mutates overrideMaterial in place and restores eight fields, none of them alpha test materialAlphaTestNode = ( overrideMaterial.isNodeMaterial ) ? overrideMaterial.alphaTestNode : null;
9 Write overrideMaterial._alphaTest so the shared override material's version does not churn per caster f30f9b6 (PRD-478) dropped-upstream r187 has the same assignment with the same rationale: overrideMaterial._alphaTest = material.alphaTest; // bypass the setter so the version doesn't change. Upstream's one-liner kept verbatim -
10 Pull alphaTestNode out of _getShadowNodes() for shadow pass materials f30f9b6 (PRD-478) kept r187 destructures only { colorNode, depthNode, positionNode } const { colorNode, depthNode, positionNode, alphaTestNode } = this._getShadowNodes( material );
11 Assign the per-caster alphaTestNode to the shadow override material f30f9b6 (PRD-478) kept paired with row 10 overrideMaterial.alphaTestNode = alphaTestNode;
12 Restore _alphaTest / alphaTestNode on the scene's override material after the draw f30f9b6 (PRD-478) kept paired with rows 8/11 scene.overrideMaterial._alphaTest = materialAlphaTest;
13 Run every draw step unconditionally instead of behind nodes.needsRefresh(), letting each component decide what a settled object may skip a602467 (PRD-458/473) dropped-upstream r187 replaced the boolean with RenderObjectRefreshType (constants.js:1711) and _renderObjectDirect gates FULL / SHARED / NONE (Renderer.js:4076-4096); the observer arm ported needsRefresh with isSettled( renderObject ) replacing object.static, and r187's equals() compares world matrix, output-target version, geometry attribute + index versions, instance matrix/colour, morph texture and material/light data — exactly the "nothing it depends on changed" set our patch computed inside the components. The upstream structure is the same fix with upstream's own plumbing, so ours was not duplicated -
14 Always call nodes.updateAfter( renderObject ) after a draw a602467 (PRD-458/473) dropped-upstream r187 calls it for every refresh type except NONE (if ( refreshType !== RenderObjectRefreshType.NONE ) this._nodes.updateAfter( renderObject )) and, when the pipeline is not ready, still invalidates the bundle version — strictly better than unconditional -

src/renderers/common/Textures.js

# intent (one line) origin (sha / PRD) decision reason marker
1 State for the single async-prepare lane 2d21247 (PRDs 466–468) kept r187 has no async lane; fields sit next to _htmlTextures this._uploadTail = Promise.resolve();
2 updateTexture() becomes the guard (pending upload / remembered upload error) and _updateTexture() the body, so an in-flight preparation cannot be raced by a sync update 2d21247 ported r187 turned updateTexture into a generation-returning early-out (return textureData.generation;) and added the render-target early path; kept both, moved them into _updateTexture, and made every non-upload exit return textureData.generation (Bindings assigns it to binding.generation) if ( this._pendingUploads.has( texture ) ) throw new Error( 'Compressed texture preparation is pending.' );
3 Dropping a destroyed render-target texture must invalidate the cached GPU bind groups that point at it a602467 / 4e8fe84 kept r187 keeps textureData.bindGroups (filled by Bindings) and the groups/versions cache objects, but has no invalidation on re-create // delete cached bind groups so they don't point to the destroyed texture
4 Upload through the async backend when the caller supplied an upload callback 2d21247 ported r187 added texture.clearUpdateRanges() right after backend.updateTexture(...) in the same block; kept that on the sync branch only and routed the upload branch to _initializeTexture + the callback uploadPromise = upload( texture, options );
5 texture.onUpdate belongs to the completion of an upload, not to its submission 2d21247 kept no upstream equivalent if ( ! upload && texture.onUpdate ) texture.onUpdate( texture );
6 Split the one-time initialisation out of the update body so the async path can initialise before uploading and still return a result 2d21247 ported r187 rewrote that block (dispose handler only for non-render-target textures, _tracked / _registry weak refs); kept the extraction and the weak-ref registration, and re-added the generation return that r187's rewrite has at the end of updateTexture _initializeTexture( texture, textureData ) {
7 updateTextureAsync() + _waitForUpload(): single preparation lane keyed by texture, device-loss/abort/dispose handling, revalidation before publishing 2d21247 kept Pure additions; needs backend.updateTextureAsync (WebGPUTextureUtils) which is in this arm updateTextureAsync( texture, options = {} ) {
8 dispose() must abort in-flight preparations before freeing GPU data 2d21247 / 4e8fe84 ported r187 added its own dispose() (weak-ref sweep + super.dispose()); merged into one method — abort the lane first, then r187's _tracked sweep this._uploadDisposed = true;
9 Disposing a texture in flight cancels its preparation 2d21247 kept r187's _destroyTexture has no such hook this._pendingUploads.get( texture )?.controller.abort( new Error( 'Texture disposed during preparation.' ) );

src/renderers/common/extras/PMREMGenerator.js

# intent (one line) origin (sha / PRD) decision reason marker
1 fromEquirectangularAsync() must fence the bake on WebGPU instead of returning while the GPU is still filtering; drop the deprecation notice because the engine's warmup uses this path 596e93b + 4e8fe84 (PRD-624) ported r187 kept warnOnce( … is deprecated … ) + return this._fromTexture( … ); dispatch now goes to _fromTextureAsync on backend.isWebGPUBackend (WebGPUBackend.js:92) return this._fromTextureAsync( equirectangular, renderTarget );
2 Same for fromCubemapAsync() 596e93b + 4e8fe84 (PRD-624) ported as row 1 return this._fromTextureAsync( cubemap, renderTarget );
3 _fromTextureAsync / _renderFacesAsync / _textureToCubeUVAsync / _applyPMREMAsync / _applyGGXFilterAsync — fence the bake in bounded slices (one cube face at most in flight) and resolve only after queue.onSubmittedWorkDone() 596e93b + 4e8fe84 (PRD-624) ported r187 rewrote the whole class: _init, _sizeLods, _lodMeshes, _pingPongRenderTarget, _flatCamera, _renderFaces, _applyGGXFilter, _setViewport and the CubeUV atlas are gone; the generator now renders each mip level with CubeCamera into a CubeRenderTarget. Re-expressed by intent on the new code: _setSizeFromTexture() factored out of r187's inline body (also used by the sync path), _fromTextureAsync() = r187 _fromTexture with the async passes plus a final fence and disposal of an internally allocated target when the bake fails, _renderCubeAsync() = r187 _renderCube's CubeCamera face loop unrolled one face at a time (same mipmapsAutoUpdate/xr.enabled/MRT/render-target restore and the same reversed-depth clear guard as CubeCamera.update), _textureToCubemapAsync()/_applyPMREMAsync() = the r187 bodies with await. The fence budget keeps its original meaning (size(LOD 1)² × GGX_SAMPLES = cost of one heavy face) with per-face cost derived from the mip size and the material's sample count await queue.onSubmittedWorkDone();

src/renderers/common/nodes/NodeManager.js

# intent (one line) origin (sha / PRD) decision reason marker
1 updateGroup() skips the per-object uniform write when the object is settled a602467 (PRD-458/473) kept r187's updateGroup( nodeUniformsGroup ) still returns true for NodeUpdateType.OBJECT; only used by Bindings._update updateGroup( nodeUniformsGroup, settled ) {
2 isSettled( renderObject ) — the one place the settled question is asked a602467 (PRD-458/473) kept Delegates to renderObject.getMonitor().isSettled(); the merged NodeMaterialObserver (other arm) has isSettled at line 376 isSettled( renderObject ) {
3 Settled objects skip their OBJECT-type updateBefore nodes a602467 (PRD-458/473) kept r187 loops nodeBuilder.updateBeforeNodes unconditionally if ( settled === true && node.getUpdateBeforeType() === NodeUpdateType.OBJECT ) continue;
4 Settled objects skip their OBJECT-type updateAfter nodes a602467 (PRD-458/473) kept r187 only has the // update frame state for each node comment here — our comment replaced it if ( settled === true && node.getUpdateAfterType() === NodeUpdateType.OBJECT ) continue;
5 Settled objects skip their OBJECT-type per-draw node updates a602467 (PRD-458/473) kept r187 loops nodeBuilder.updateNodes unconditionally if ( settled === true && node.getUpdateType() === NodeUpdateType.OBJECT ) continue;

src/renderers/webgpu/WebGPUBackend.js

# intent (one line) origin (sha / PRD) decision reason marker
1 Hold the GPU instance and the adapter for the lifetime of the backend — a collected adapter takes its instance with it and every in-flight operation then fails with "A valid external Instance reference no longer exists" f1040a1 (PRDs 269/455) kept r187 still releases the adapter (const adapter = … await navigator.gpu.requestAdapter( … ) and nothing else holds it) this.adapter = adapter;
2 Rebuild the canvas render-pass descriptor when depth / stencil / reversedDepthBuffer change, not only the sample count 2d21247 (PRDs 466–468) kept r187's guard is only descriptor === undefined || canvasData.samples !== samples canvasData.reversedDepthBuffer !== renderer.reversedDepthBuffer
3 Transient-depth speculation: helpers that swap the canvas depth for a pass-local MSAA depth and prove a full-viewport, identity-transform triangle before keeping it 1d056ce (#486), f30f9b6 (PRD-478) kept All new methods (_isFullTransientViewport, _beginTransientDepth, _finishTransientDepth, _restoreTransientDepth, _resumeTransientDepth, _abortTransientDepth, _releaseTransientDepth, _validateTransientDepth); names they touch all exist in r187 (renderer.getCanvasTarget(), textureUtils._depthBuffers (this arm), renderObject.getIndirect(), renderObject.getIndex(), renderObject.getBindings()) _beginTransientDepth( context, descriptor ) {
4 beginRender keeps the (possibly swapped) descriptor and the encoder on the render-context data 1d056ce kept r187 keeps only a local descriptor / encoder descriptor = this._beginTransientDepth( renderContext, descriptor );
5 finishRender folds the speculation back before the occlusion query resolve and the submit f30f9b6 (PRD-478) kept no upstream hook this._finishTransientDepth( renderContext );
6 Submit inside try/finally and always release the private depth (and mark it reusable once the discard was really encoded) 1d056ce / f30f9b6 kept r187 calls submit( this.device, renderContextData.encoder.finish() ) bare this._releaseTransientDepth( renderContext );
7 A viewport change to anything but the full viewport must give the canonical depth back 1d056ce kept r187's updateViewport only writes the pass viewport if ( ! this._isFullTransientViewport( renderContext ) ) this._restoreTransientDepth( renderContext );
8 Same for scissor 1d056ce kept r187's updateScissor only writes the pass scissor this._restoreTransientDepth( renderContext );
9 Record that real (non-proven) draws happened into the speculative pass, so the discard is undone f30f9b6 (PRD-478) kept r187's _draw has no such state renderContextData._tnDepth.logical = 0;
10 Validate the speculation per draw (identity matrices, shipped projection chain, no discard / frag_depth / sample_mask / clip_distances, plain non-indexed triangle) and fall back when it does not hold f30f9b6 (PRD-478) kept Reads _tnDepthProof (WebGPUPipelineUtils, this arm), _tnBindings / _tnUniformBytes (WebGPUBindingUtils, this arm), _tnFullTriangle / _tnTriangleVersion / uploadCall (WebGPUAttributeUtils, this arm) _validateTransientDepth( renderObject, params, vertexBuffers ) {
11 Bind-group comparison must compare the native bind group object, not the binding id f30f9b6 (superseded) dropped-upstream r187 does exactly this with one call instead of two: const bindGroupGPU = this.get( bindGroup ).group; … if ( currentBindingGroups[ i ] !== bindGroupGPU ) (WebGPUBackend.js, _draw bind-group loop) -
12 Store the native bind group object in the per-pass binding cache f30f9b6 (superseded) dropped-upstream as row 11 — currentBindingGroups[ i ] = bindGroupGPU; -
13 A changed bind-group layout must force a new render pipeline a09cea3 (PRD-566) ported r187's needsUpdate condition ends at data.clippingContextCacheKey; kept upstream's data.geometryVersion term and added ours after it data.bindGroupLayoutKeys !== bindGroupLayoutKeys
14 … and the layout keys have to be stored with the rest of the pipeline state a09cea3 (PRD-566) kept r187 stores geometryVersion + clippingContextCacheKey only data.bindGroupLayoutKeys = bindGroupLayoutKeys;
15 Bind-group layout keys are part of the pipeline cache key a09cea3 (PRD-566) kept r187's getRenderCacheKey has no layout term `const bindGroupLayoutKeys = renderObject.getBindings().map( bindGroup => this.get( bindGroup ).layoutKey ).join( '
16 Pipeline key uses the structural geometry key without morph-target ids a09cea3 (PRD-566) dropped-upstream r187 replaced that entry with utils.getStripIndexFormat( object, geometry ) + this.attributeUtils.getVertexBufferLayoutCacheKey( renderObject ), which keys on the actual vertex layout (see RenderObject#3) -
17 Backend passthrough for the cold async compressed-texture path 2d21247 kept this.textureUtils.updateTextureAsync is in this arm updateTextureAsync( texture, options ) {
18 Keep the generated shader source with the compiled program so the transient-depth proof can compare it f30f9b6 / 1d056ce kept r187 stores only { module, entryPoint } programGPU._tnShaderCode = program.code;
19 Allocate the CPU-side mirror of a uniform buffer only when it can carry matrices 1d056ce kept r187 allocates no mirror uniformBufferData._tnUniformBytes = new Float32Array( byteLength / 4 );
20 (hunk touched only a blank line before copyTextureToTexture's mipmap guard) 4e8fe84 (PRD-624) dropped-obsolete our hunk adds one empty line and changes no behaviour; r187's guard is dstLevel === 0 && dstTexture.generateMipmaps === true && dstTexture.mipmapsAutoUpdate === true, which is kept verbatim -
21 A framebuffer copy resumes the canonical depth pass first 1d056ce kept r187's copyFramebufferToTexture starts with const renderContextData = this.get( renderContext ); this._restoreTransientDepth( renderContext );

src/renderers/webgpu/nodes/WGSLNodeBuilder.js

# intent (one line) origin (sha / PRD) decision reason marker
1 textureLoad on a multisampled or raw (non-sampler) texture must take an i32 sample index — naga v25 (wgpu-native) rejects u32 there c8546d0 (#279) kept r187 still emits u32( ${ levelSnippet } ) unconditionally; the added 7th parameter is already passed by TextureNode.js:635 (this.isRawTextureNode === true, other arm) `const indexType = ( rawTexture
2 A raw texture node never gets a sampler in the bind group layout f1040a1 (PRDs 269/455) kept r187's needsSampler has no raw-texture term node.isRawTextureNode !== true &&
3 Do not name uniform buffers with a process-wide allocation id (it changes identical shader source per buffer) 4e8fe84 / #289 dropped-upstream r187 removed the very line our hunk rewrote (uniformNode.name = name ? name : 'NodeBuffer_' + uniformNode.id; is gone from getUniformFromNode), so naming is left to the base builder -
4 @invariant on the vertex position output for the temporal passes f1040a1 (PRDs 269/455) ported r187 added the same @invariant behind this.context.invariant === true (WGSL and GLSL builders). The engine (and its starter templates) set the flag through TSL: renderer.contextNode = context( { invariantPosition: true } ), so dropping invariantPosition would silently disable the fix. Accept both flags this.context.invariantPosition === true
5 Sample-rate interpolation policy for the temporal passes (context( { sampleInterpolation } )), keeping flat/integer varyings alone f1040a1 (PRDs 269/455) kept r187's getVaryings honours only varying.interpolationType const policy = this.context.sampleInterpolation;
6 The fragment stage must derive needsSampler from the layout frozen in getUniformFromNode (nodeData.uniformGPU), not from live texture filter state, or a rebuilt shader shifts every later binding index f1040a1 (PRDs 269/455) kept r187 recomputes from texture.isCubeTexture / isUnfilterable; getDataFromNode( node, stage, globalCache ) and uniformGPU exist in r187's builder const needsSampler = Array.isArray( uniformGPU ) && uniformGPU[ 0 ].isSampler === true && uniformGPU[ 0 ].isSampledTexture !== true;
7 A raw texture node binds as texture_multisampled_2d (it is a resolved-or-not MSAA attachment view) f1040a1 (PRDs 269/455) kept r187 checks primarySamples > 1 only `if ( primarySamples > 1

src/renderers/webgpu/utils/WebGPUAttributeUtils.js

# intent (one line) origin (sha / PRD) decision reason marker
1 DynamicDrawUsage is needed to decide whether a buffer may be re-uploaded twice in one render call f30f9b6 (PRD-478) kept r187 imports only Float16BufferAttribute from BufferAttribute.js import { DynamicDrawUsage } from '../../../constants.js';
2 Record which buffer holds the engine's full-screen triangle, its version and its upload call f30f9b6 / 1d056ce kept _validateTransientDepth (WebGPUBackend, this arm) verifies the bytes; r187 records nothing bufferData._tnFullTriangle = backend.device.features?.has( 'transient-attachments' )
3 Re-upload a shared buffer whose dirty bytes belong to a fresh attribute wrapper, and never repeat a write already made in this render call f30f9b6 / 1d056ce kept r187's updateAttribute re-writes unconditionally; the guard is what makes a re-built shader wrap an existing buffer safely bufferData._tnFullTriangle = false;
4 Track the version and the render call of the last upload f30f9b6 / 1d056ce kept r187 stores only _itemSize bufferData.uploadCall = backend.renderer.info.calls;
5 Destroying an interleaved attribute must also forget the destroyed buffer on its InterleavedBuffer, as the WebGL backend does, or the next createAttribute() submits a destroyed buffer f30f9b6 (PRD-478) ported r187 rewrote the same method (backend.getBufferAttribute( attribute ), guard-less data.buffer.destroy(), single backend.delete( bufferAttribute )); kept upstream's public accessor and added our guard + interleaved-entry cleanup if ( bufferAttribute !== attribute ) backend.delete( bufferAttribute );

src/renderers/webgpu/utils/WebGPUBindingUtils.js

# intent (one line) origin (sha / PRD) decision reason marker
1 Re-create the GPU bind-group layout when the entry set changed, instead of reusing the first layout this bind group ever had 32ebfa8 (PRD-400) kept r187 returns bindingsData.layout.layoutGPU whenever a layout exists; deleteBindGroupData( bindGroup ) exists at r187 line 622 if ( bindingsData.layoutKey === bindGroupLayoutKey ) return bindingsData.layout.layoutGPU;
2 Keep the binding description with the GPU bind group for the transient-depth proof f30f9b6 / 1d056ce kept _tnGroups is filled by row 5 bindingsData._tnBindings = this._tnGroups?.get( bindGroupGPU );
3 Mirror the bytes actually written to a uniform buffer (including partial ranges) f30f9b6 / 1d056ce kept r187's updateBinding only issues writeBuffer data._tnUniformBytes = values.slice();
4 A raw pass attachment binds a fresh 2D view of the MSAA/depth texture instead of the default view f1040a1 (PRDs 269/455) kept r187's sampled-texture branch starts at textureData.externalTexture !== undefined; textureData.msaaTexture still exists in r187 (WebGPUTextureUtils.js:467) alongside the new msaaTextures array used for layered targets textureData.rawTextureView = raw.createView(
5 Capture the binding descriptions of a new GPU bind group f30f9b6 / 1d056ce kept r187 returns createBindGroup( … ) without bookkeeping this._tnGroups.set( bindGroupGPU,
6 Raw attachments count as multisampled when the sampler/bind-group state is computed f1040a1 (PRDs 269/455) kept r187 checks primarySamples > 1 only `if ( primarySamples > 1
7 A multisampled FloatType texture cannot use a filtering sampler even when float32-filterable exists f1040a1 (PRDs 269/455) kept r187's guard is this.backend.hasFeature( 'float32-filterable' ) only if ( texture.multisampled !== true && this.backend.hasFeature( 'float32-filterable' ) ) {
8 Deleting a bind group's layout data also drops the cached GPU bind groups built against it 32ebfa8 (PRD-400) kept r187's deleteBindGroupData clears layout/layoutKey and leaves groups/versions pointing at bind groups of the old layout bindingsData.groups = [];

src/renderers/webgpu/utils/WebGPUPipelineUtils.js

# intent (one line) origin (sha / PRD) decision reason marker
1 Capture the immutable pipeline proof (depth state, topology, cull, position format/offset/stride, compiled programs) used by the transient-depth speculation, and record the pipeline object on it 1d056ce (#486) / f30f9b6 (PRD-478) kept r187 builds the descriptor and the pipeline with no such record; backend.get( vertexProgram ) / _tnShaderCode are provided by this arm pipelineData._tnDepthProof = depthStencil.depthWriteEnabled === true
2 Pop the error scope before awaiting the pipeline and handle its rejection, so an unrelated draw cannot enter the scope and no rejection escapes as unhandled 2d21247 (PRDs 466–468) ported r187 already pops before awaiting (const errorScopePromise = device.popErrorScope(); + comment); kept that ordering and added our .catch( … asyncError ??= err ) so a rejected scope is reported instead of thrown asyncError ??= err;

src/renderers/webgpu/utils/WebGPUTextureUtils.js

# intent (one line) origin (sha / PRD) decision reason marker
1 The default canvas owns its private depth variants (one per effective sample count) until resize/dispose 1d056ce (#486) kept r187 has no such cache; DepthTexture import comes with it this._depthBuffers = new Map();
2 A 3D texture that is not rendered into must not ask for RENDER_ATTACHMENT (wgpu-native rejects 3D render attachments) afee25e (PRD-494) kept r187's condition is isCompressedTexture !== true && isCompressedArrayTexture !== true && format !== RGB9E5UFloat; dimension, options.needsMipmaps and options.renderTarget all exist in r187's createTexture const notAnAttachment3D = dimension === GPUTextureDimension.ThreeD &&
3 Destroying the canvas depth texture tears down the whole cache (private buffer, listeners, reused variants) 1d056ce / f30f9b6 kept r187's destroyTexture only destroys that one texture (plus its msaa textures) depthBuffers._tnPrivateDepth?.texture.destroy();
4 The canvas colour texture is only reusable when its GPU texture exists, and it is sized through image, not source 2d21247 (PRDs 466–468) dropped-upstream r187 made both changes: if ( colorTextureData.texture !== undefined && colorTexture.width === width && … ) and colorTexture.image.width = width; -
5 getDepthBuffer() returns a cached, correctly formatted depth buffer per sample count instead of recreating on every mismatch 1d056ce (#486) ported r187 still has the 0.185.1 body (early-out on width/height/samples/depth/stencil, then createTexture( depthTexture )). Our replacement was written against that body and is kept whole; it only reads currentSamples, getSampleCount, createTexture/destroyTexture, all unchanged in r187. Upstream's depth/stencil early-out is intentionally folded into the format/type cache check let cache = this._depthBuffers.get( depthTexture );
6 updateTextureAsync() — tile the compressed mip data under a host-time and bytes-per-write budget, scope the writes, revalidate before publishing 2d21247 (PRDs 466–468) kept Pure addition; writes through device.queue.writeTexture directly, so upstream's writeTextureLayer( …, dataOffset, … ) signature change and the new _getBlockData() fallback do not affect it (_getBlockData is now total, which only weakens our "unsupported allocation" guard) async updateTextureAsync( texture, options = {} ) {

Doubts, ranked by risk

  1. Renderer.js rows 13/14 (always-run vs RenderObjectRefreshType) — highest risk. I dropped our "every step of a draw runs" in favour of r187's FULL/SHARED/NONE gate, because r187's NodeMaterialObserver.equals() compares the same signals our settled path compared (attribute and index versions, instance matrix/colour versions, morph-texture version, output-target version, world matrix) and the observer arm replaced object.static with isSettled. If that equivalence is wrong for one case (a geometry attribute written while the refresh type stays NONE), the symptom is a stale vertex buffer. The defensive code our patch put inside Bindings._update / Geometries.attributesChanged is still present and still correct — it just no longer runs on NONE frames.
  2. PMREMGenerator.js row 3 (largest rewrite). r187 replaced the CubeUV generator with a per-mip CubeCamera design, so the per-face fence had to be re-expressed. My _renderCubeAsync() duplicates CubeCamera.update()'s per-face body (coordinate-system update, mipmapsAutoUpdate off, xr off, MRT null, reversed-depth clear, render-target/cube-face/mipmap restore) and sets needsPMREMUpdate. It is the only place in the arm where I wrote rendering code that upstream does not have. Everything else in that file is r187 code with awaits; _applyPMREMAsync deliberately duplicates _applyPMREM's loop (lodBias math included) to keep the upstream function untouched — a future upstream change to that math must be mirrored.
  3. invariantPosition vs upstream invariant (WGSLNodeBuilder row 4). I accept both, but the engine's templates ("renderer.contextNode = context({ invariantPosition: true })") depend on the old name; if the engine ever renames the flag, both builders must move together (WGSL here, GLSL in another arm's file — the GLSL builder in r187 only reads context.invariant, so a invariantPosition-only scene gets @invariant in WGSL and nothing in GLSL. That asymmetry already existed in 0.185.1, where the engine only patched the WGSL builder).
  4. Bindings.js row 5 (settled sampled-texture generation). The settled branch sets needsBindingsUpdate but appends neither the texture id nor its version to the (cacheKey, version) pair WebGPUBindingUtils.createBindings uses, so a re-created texture could still hit the cached GPU bind group. This is our patch's behaviour as written; upstream's own non-settled path has the same hole for a texture that is re-created without a version bump, so I did not redesign it here.
  5. WebGPUTextureUtils.js rows 5/6 and _getBlockData. r187 made _getBlockData() total (uncompressed fallback), so updateTextureAsync's "unsupported allocation" throw can no longer fire for a format it does not list; harmless for the compressed-only caller but the guard is now weaker than its comment.
  6. WebGPUBindingUtils.js row 8 sets groups/versions to [] (our patch) while Textures._destroyTexture sets them to undefined. Both invalidate the cache; the sibling path is the one r187 owns, so I left both as they are rather than harmonise.
  7. Cross-arm dependencies I could not edit (verified to exist in the merged tree at the time of writing): NodeMaterialObserver.isSettled (line 376), TextureNode.generateTextureLoad( …, isRawTextureNode ) (line 635) and PassNode.isRawTextureNode / _rawTexturesDisposed, Material._alphaTest. If any of those arms drops its half, the ported hunks that read them go inert.

…o temporalAA.ts stays under 200 lines (PRD-775)

looks.spec keeps every generated render file under 200 lines; the r187 claim had grown temporalAA.ts to 209.
Same behaviour: claim() checks the r187 render pipeline state, takes the view offset, captures the context.
CI run 41233630666 test-unit (3/4): quality-json found a new biome-ignore at temp-node.ts:111.
A shorthand property from a local const carries the same export without the waiver.
…op merge

CI run 41233630666 budgets: src/, tests/, cmake/ and four more rows drifted past tolerance 5.
…n it (PRD-775)

CI run 41240800887 template-nonvisual (sailing): after exactly 7200 ticks the game's elapsed sum is
119.99999999999447 s, so wind = 1 - elapsed / 120 is 4.6e-14, not 0, and the status is still
sailing. Develop passed only because startup frames stepped the game first (its held warm-up timed
out at 5 s; on three 0.187 it completes in 2.7 s), a margin that depended on load. The scenario now
waits 7260 ticks, one second past the boundary. Local, CI flags (webgpu, headed): both float
scenarios pass, exit 0.
AC-8 ticked: paired presented p95 on Midway and the stress lab, web and native desktop, NEUTRAL;
native:verify:desktop exit 0; CI run 41248394799 on c38dcde adds no red develop does not have
(linux-arm64 and its receipt, and the adventure, collectathon, rain and shooter timeouts fail the
same on develop runs 41230261223 and 41226270743). The PRD moves to done/ and the Unreal port
status regenerates.
@jonit-dev jonit-dev added prd:100% — ready PRD complete, ready to merge and removed prd:75% PRD phases in, acceptance open labels Nov 1, 2026
@jonit-dev
jonit-dev marked this pull request as ready for review November 1, 2026 00:04
Run 41248394799 on c38dcde still fails linux-arm64, its coverage receipt and the adventure,
collectathon, rain and shooter timeouts. They fail on develop too, but AC-8 asks for a green board,
so the box stays open until they pass.
@jonit-dev jonit-dev added prd:75% PRD phases in, acceptance open and removed prd:100% — ready PRD complete, ready to merge labels Nov 1, 2026
@jonit-dev
jonit-dev marked this pull request as draft November 1, 2026 00:05
…loor to three 0.187

Develop's eac89e9 floors the patched PMREMGenerator's fence budget at a 256 cube's heaviest
face. The same floor goes into three@0.187.0.patch (src and both builds). On r187 it changes no
fence: PMREMGenerator's MIN_SIZE is 256 there, so a 64 x 32 sky already bakes a 256 cube, the
same bake as a 1k sky (15 fences in the stub). pmrem-fence.spec.ts gains the renderer fields r187
reads (coordinateSystem, xr, reversedDepthBuffer) and asserts that equivalence instead of the r185
one-fence case. Scaffold hashes re-pinned for develop's sky changes and the new patch bytes.
# Conflicts:
#	packages/core/patches/three@0.185.1-prd269-upgrade.patch
@jonit-dev jonit-dev added prd:100% — ready PRD complete, ready to merge and removed prd:75% PRD phases in, acceptance open labels Nov 1, 2026
@jonit-dev
jonit-dev marked this pull request as ready for review November 1, 2026 10:20
# Conflicts:
#	docs/verification/native-coverage-2026-08-28.md
#	packages/create-threenative/templates/adventure/package.json
#	packages/create-threenative/templates/collectathon/package.json
#	packages/create-threenative/templates/kart/package.json
#	packages/create-threenative/templates/minimal/package.json
#	packages/create-threenative/templates/platformer/package.json
#	packages/physics/package.json
…oves mid-walk

The guard walks packages/ while starter-post-bundle creates and removes temporary projects there,
so a vanished file failed the guard with ENOENT. The asset cook's pending journal also stays out of
the example's tree, beside the outputs it names.
…not two seconds in

A fixed two-second delay ran past the reveal once startup got faster: the first 1.6 s the player saw
drew 731 calls where 104 hold. Readiness follows a sustained window of real frames, which is what
the delay stood in for. PRD-775 also stops claiming a fix for a parity row that flips on one head.
# Conflicts:
#	packages/create-threenative/templates/adventure/package.json
#	packages/create-threenative/templates/collectathon/package.json
#	packages/create-threenative/templates/kart/package.json
#	packages/create-threenative/templates/minimal/package.json
#	packages/create-threenative/templates/platformer/package.json
#	packages/physics/package.json
…eline, not the launch

The engine's frame series starts when compilation settles, before readiness, so a scenario also
answered for frames behind the loading cover, where the shooter's resident pools draw on purpose.
The baseline is read from a running game, and a host without frame numbers keeps its whole series.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:100% — ready PRD complete, ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant