Skip to content

Improve deferred decal rendering performance - #1

Open
jamesx0416 wants to merge 26 commits into
v10from
codex/decal-rendering
Open

Improve deferred decal rendering performance#1
jamesx0416 wants to merge 26 commits into
v10from
codex/decal-rendering

Conversation

@jamesx0416

@jamesx0416 jamesx0416 commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • isolate deferred blend-G-buffer decal rendering from Milestone 1 interaction work
  • improve decal coverage, occlusion, receiver projection, and resolve efficiency
  • remove avoidable decal projection work on the hot path

Validation

  • bun test
  • bun run typecheck

Summary by CodeRabbit

  • New Features

    • Added WebGPU deferred rendering support for aircraft scenes, with forward-rendering fallback when unavailable.
    • Improved handling of aircraft decals, transparency, lighting, and material details.
    • Added automatic render-target resizing and cleanup for more reliable graphics performance.
  • Bug Fixes

    • Improved decal projection, depth handling, occlusion, and surface alignment.
    • Reduced visual instability affecting aircraft decals and specular highlights.
  • Performance

    • Deferred rendering is automatically disabled in cockpit view to maintain smooth performance.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds receiver-aware MSFS decal projection, WebGPU aircraft G-buffer resources, deferred-lighting material writers, deferred render-pass orchestration, forward fallback, and cockpit-dependent activation. Tests cover projection, materials, G-buffer resources, and pass selection.

Changes

MSFS deferred rendering

Layer / File(s) Summary
Receiver-aware decal projection
src/msfs/gltf/normalizeMsfsMaterials.ts, src/msfs/gltf/normalizeMsfsMaterials.test.ts
Decal geometry is clipped, refined, and reconstructed against visible receiver topology. Normals, tangents, skin attributes, draw order, occlusion, and depth allowances are preserved.
G-buffer material writers
src/msfs/gltf/normalizeMsfsMaterials.ts, src/msfs/gltf/normalizeMsfsMaterials.test.ts
Material normalization can create G-buffer writers and deferred-lighting materials. Color, opacity, alpha maps, vertex colors, coverage, MRT outputs, and blend factors are handled by the unified node pipeline.
Aircraft G-buffer resource
src/rendering/createAircraftGBuffer.ts, src/rendering/createAircraftGBuffer.test.ts
WebGPU rendering now provides four color attachments, depth storage, texture nodes, capability detection, resize handling, disposal, and layout tests.
Deferred render-pass integration
src/rendering/createMsfsRenderPasses.ts, src/rendering/createMsfsRenderPasses.test.ts, src/main.ts, package.json, NOTES.md
Render passes select deferred or forward decal paths, collect visible relationships, batch resolves, restore forward state, and disable deferred rendering during cockpit view. The three constraint is updated to ^0.183.0.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AnimationLoop
  participant MsfsRenderPasses
  participant AircraftGBuffer
  participant SceneMaterials
  AnimationLoop->>MsfsRenderPasses: setDeferredEnabled(cockpitViewInactive)
  MsfsRenderPasses->>SceneMaterials: collect visible decals and receivers
  MsfsRenderPasses->>AircraftGBuffer: render G-buffer passes
  MsfsRenderPasses->>SceneMaterials: render deferred resolve batches
  MsfsRenderPasses-->>AnimationLoop: restore rendering state
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the PR's main change: improving deferred decal rendering performance.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/decal-rendering

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 29b6f853e6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +647 to +649
if (!isDecalProjectionVisible(decalPrimitive.mesh, triangleIndex, occluderIndex)) {
continue
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid rejecting an entire partially occluded decal

When a same-parent decal has even one vertex closer to a descendant mesh than to the candidate receiver, isDecalProjectionVisible rejects the entire primitive, including portions that are unobstructed. Ordered or component-only decals then disappear because the forward path hides unprojected materials, while unordered color decals remain as detached shells. This all-or-nothing geometric heuristic should be replaced with per-region clipping or an authoritative receiver relationship.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

Comment on lines +2679 to +2680
isBlendGBufferMaterial && buildGBufferWriter
? 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve authored normal factors in the forward fallback

For a projected blend-G-buffer material with a writer, this changes the source material's normal scale to full strength instead of applying blendFactors.normal. That source material is still used whenever deferred rendering is unavailable and is explicitly used after src/main.ts disables deferred rendering in cockpit view, so decals with a zero or fractional normal factor incorrectly apply their full normal map in those contexts. Keep the forward material scaled by the authored factor and give only the writer an unscaled copy.

Useful? React with 👍 / 👎.

Comment on lines +2783 to +2784
if (gBufferWriter != null) {
outputMaterial.userData.msfsGBufferWriter = gBufferWriter

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Dispose the attached G-buffer writer with its source material

When an exterior component is replaced or reloaded, disposeObjectResources disposes only each mesh's top-level material; Three.js does not recursively dispose a second Material stored in userData. Consequently every compiled writer assigned here survives exterior LOD/settings reloads without a disposal event, retaining its renderer resources and texture references. The component teardown path should explicitly dispose and clear this attached writer.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (15)
src/msfs/gltf/normalizeMsfsMaterials.test.ts (4)

140-147: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fail loudly on multi-component swizzles.

Line 145 uses 'xyzw'.indexOf(node.components). For a multi-component swizzle such as 'xyz' or 'rgb', indexOf returns -1 and Line 146 returns undefined. The helper then produces NaN downstream instead of an error.

The current assertions never reach that path, so this is a latent trap only. Add an explicit check so a future graph change fails with a clear message.

♻️ Proposed change
     const index = 'xyzw'.indexOf(node.components)
+    if (index < 0) {
+      throw new Error(`Unsupported swizzle components: ${node.components}`)
+    }
     return source[index]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/msfs/gltf/normalizeMsfsMaterials.test.ts` around lines 140 - 147, Update
the split-node handling around evaluateCoverageNode so multi-component or
otherwise unsupported node.components values are rejected explicitly before
indexing; validate that the swizzle resolves to a single valid xyzw component,
and throw a clear error instead of returning undefined and allowing NaN
downstream.

597-627: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the case where the receiver carries no skin influences.

This test gives the receiver both skinIndex and skinWeight, so it exercises only the hasProjectedSkin === true path in rebuildConformedDecalGeometry.

When the receiver has no skin influences, that flag stays false and the decal's own skinIndex runs through the generic barycentric interpolation loop. I raised that defect on src/msfs/gltf/normalizeMsfsMaterials.ts at Line 1666. A test with an unskinned receiver and a skinned decal would pin the fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/msfs/gltf/normalizeMsfsMaterials.test.ts` around lines 597 - 627, The
test around normalizeMsfsMaterials should also cover an unskinned receiver with
a skinned decal. Add a case where the receiver lacks skinIndex and skinWeight
while the decal has those attributes, then assert the decal does not retain or
generically interpolate its skin influences after rebuildConformedDecalGeometry.
Preserve the existing projected normal and tangent assertions.

189-193: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert values directly instead of wrapping comparisons in toBe(true).

Line 189, Line 193, Line 252, Line 298, Line 325, Line 350 and Line 377 all compute a boolean and then assert toBe(true). On failure Bun reports only expected true, received false. The actual coordinate or count is lost, which makes projection regressions slow to diagnose.

bun:test provides toBeCloseTo and toBeLessThan, which report both operands.

♻️ Example for Line 189 and Line 298
-  expect(position.count === 3).toBe(true)
+  expect(position.count).toBe(3)
-  expect(Math.abs(decal.geometry.getAttribute('position').getZ(0) + 0.1) < 1e-6).toBe(true)
+  expect(decal.geometry.getAttribute('position').getZ(0)).toBeCloseTo(-0.1, 6)

Also applies to: 251-256, 277-278, 298-298, 325-325, 350-350, 373-378

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/msfs/gltf/normalizeMsfsMaterials.test.ts` around lines 189 - 193, Update
the assertions in the affected normalizeMsfsMaterials tests to assert values
directly rather than comparing boolean expressions with toBe(true). Replace
count equality checks with direct equality assertions and tolerance checks with
toBeCloseTo or toBeLessThan, preserving the existing expected values and
thresholds so failures report the actual operands.

404-432: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for the independent-ORM rejection branch.

createMsfsGBufferWriter returns null when blendFactors.roughness differs from blendFactors.metallic or blendFactors.occlusion. That branch disposes the writer and forces src/rendering/createMsfsRenderPasses.ts into a fallback path.

No test covers it. A decal material with, for example, roughnessBlendFactor: 0.5 and metallicBlendFactor: 1 should yield getMsfsGBufferWriter(...) === null.

Do you want me to draft that test case?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/msfs/gltf/normalizeMsfsMaterials.test.ts` around lines 404 - 432, The
existing G-buffer writer test lacks coverage for independent ORM blend factors.
Extend the test in the `builds G-buffer writers only for node-compatible
materials` case, or add a focused case, using a decal material whose
`ASOBO_material_blend_gbuffer` sets different roughness and metallic factors
(for example 0.5 and 1), and assert that `getMsfsGBufferWriter(...)` returns
null.
src/msfs/gltf/normalizeMsfsMaterials.ts (1)

1093-1105: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Clone projectionNormal before you mutate it.

projectionNormals is typed ReadonlyMap<number, Vector3>, but Line 1095 and Line 1105 mutate the stored Vector3 in place. Line 1105 converts it into yAxis. Today buildBlendGBufferProjectionNormals stores a distinct clone per triangle, so no corruption occurs. If that helper is ever changed to share one instance across a connected component, every triangle after the first would read a corrupted normal. Clone at the read site to remove the coupling.

♻️ Proposed change
-    const projectionNormal = projectionNormals.get(triangleOffset / 3) ?? sourceNormal
+    const projectionNormal = (projectionNormals.get(triangleOffset / 3) ?? sourceNormal).clone()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/msfs/gltf/normalizeMsfsMaterials.ts` around lines 1093 - 1105, Clone the
normal retrieved from projectionNormals before any mutation in the triangle
projection flow. Update the projectionNormal initialization near sourceNormal so
both mapped and fallback normals are copied, preserving the existing negation
and cross-product behavior without mutating stored Vector3 instances.
src/rendering/createMsfsRenderPasses.ts (5)

633-652: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider rejecting lightMap alongside aoMap.

The list rejects aoMap, alphaMap, bumpMap, and the other spatially varying inputs, because projected geometry does not carry the receiver's original UVs. lightMap belongs to the same category and is not checked. A receiver with a baked lightMap would pass this predicate and then write an incorrect G-buffer value from projected geometry.

♻️ Proposed change
     bumpMap?: unknown
     colorNode?: unknown
     displacementMap?: unknown
     emissiveMap?: unknown
     emissiveNode?: unknown
+    lightMap?: unknown
     map?: unknown
     source.displacementMap == null &&
     source.emissiveMap == null &&
+    source.lightMap == null &&
     source.map == null &&
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rendering/createMsfsRenderPasses.ts` around lines 633 - 652, Update the
material eligibility predicate to reject materials with a non-null lightMap,
alongside aoMap and the other UV-dependent map checks. Keep the existing
acceptance behavior unchanged for materials without lightMap.

449-453: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

configureDeferredDecalWriterDepthTest only ever sets depthTest = true.

The helper at Lines 757-770 assigns true unconditionally. It is called at Line 449 before the decal G-buffer pass and again at Line 488 in the finally block. Both calls produce the same state, so neither call changes anything that the other does not.

If a pass was meant to disable depth testing on the writers, that branch is missing. If not, remove one call and rename the helper to describe the invariant, or drop it and set depthTest once when the writer materials are created.

Also applies to: 757-770

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rendering/createMsfsRenderPasses.ts` around lines 449 - 453, Resolve the
redundant configureDeferredDecalWriterDepthTest calls around the decal G-buffer
pass and its finally block. Since the helper only assigns depthTest = true,
either add the missing disable branch if the pass requires it, or remove the
duplicate call and rename the helper to reflect the invariant, preferably
setting writer material depth testing once during creation.

397-403: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoidable per-frame work remains in the deferred render path. The PR goal is to remove work from the hot path, but render still allocates scratch objects on entry and repeats map lookups in a helper it calls six times per frame.

  • src/rendering/createMsfsRenderPasses.ts#L397-L403: hoist the Color scratch to module scope next to drawingBufferSize, and hoist the originalMaterialState map into the closure with a .clear() at the start of render.
  • src/rendering/createMsfsRenderPasses.ts#L734-L743: iterate sourceMaterials entries once instead of calling .get(mesh) twice per mesh, and avoid the per-call array allocation for array materials.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rendering/createMsfsRenderPasses.ts` around lines 397 - 403, The deferred
render path should avoid per-frame scratch allocations and redundant material
lookups. In src/rendering/createMsfsRenderPasses.ts:397-403, hoist the Color
scratch object to module scope beside drawingBufferSize, move
originalMaterialState into the render closure, and clear it at the start of
render. In src/rendering/createMsfsRenderPasses.ts:734-743, update the
material-state helper to iterate sourceMaterials entries once, reuse each
entry’s material value, and handle array materials without allocating a new
array on every call.

60-71: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use selectMsfsDecalRenderPath in createMsfsRenderPasses, or remove it.

The helper has no production call site. Only src/rendering/createMsfsRenderPasses.test.ts uses it. The factory keeps a separate inline decision, so the tested rule can diverge from production behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rendering/createMsfsRenderPasses.ts` around lines 60 - 71, Update
createMsfsRenderPasses to use selectMsfsDecalRenderPath for its decal
render-path decision, removing the duplicate inline logic and keeping production
behavior aligned with the tested helper; alternatively remove the unused helper
and update its tests if the factory’s inline decision is the intended source of
truth.

539-563: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid recomputing transforms in canShareResolveBatch.

syncResolveBatch invokes this predicate for every selected batch on every render. The predicate updates both meshes and compares full matrices, then syncResolveBatch updates the source again. Update transforms once in the caller and keep the predicate side-effect-free.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rendering/createMsfsRenderPasses.ts` around lines 539 - 563, Update
syncResolveBatch to call updateMatrix() once for each candidate mesh before
invoking canShareResolveBatch, then remove the transform updates from
canShareResolveBatch. Keep canShareResolveBatch side-effect-free while retaining
its existing matrix and layer comparisons.
src/rendering/createMsfsRenderPasses.test.ts (2)

88-96: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Cover the boolean and numeric rejection branches, and match the cast style used elsewhere.

canWriteReceiverGBufferFromProjectedGeometry has two structurally different groups of checks: == null checks on maps and nodes, and value checks on transparent, alphaHash, alphaTest, and vertexColors. This test covers only the first group.

Line 94 also uses as any, while src/rendering/createAircraftGBuffer.test.ts Line 27 uses as never for the same purpose.

💚 Proposed change
-  ;(material as any).normalNode = {}
+  ;(material as never as { normalNode: unknown }).normalNode = {}
   expect(canWriteReceiverGBufferFromProjectedGeometry(material)).toBe(false)
+  ;(material as never as { normalNode: unknown }).normalNode = null
+  material.transparent = true
+  expect(canWriteReceiverGBufferFromProjectedGeometry(material)).toBe(false)
+  material.transparent = false
+  material.alphaTest = 0.5
+  expect(canWriteReceiverGBufferFromProjectedGeometry(material)).toBe(false)
+  material.alphaTest = 0
+  material.vertexColors = true
+  expect(canWriteReceiverGBufferFromProjectedGeometry(material)).toBe(false)
 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rendering/createMsfsRenderPasses.test.ts` around lines 88 - 96, Extend
the test for canWriteReceiverGBufferFromProjectedGeometry to cover rejection
when transparent, alphaHash, alphaTest, or vertexColors have non-default boolean
or numeric values, while retaining the existing map and normalNode checks.
Update the normalNode assignment to use the established as never cast style,
matching createAircraftGBuffer.test.ts.

75-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case where originalMaterialState already holds an entry.

The test passes an empty Map, so preserveMaterialRenderState saves the current values and every ?? fallback in configureBlendGBufferMaterialsForDecalPass resolves to those same values. The branch that matters in production is the other one: the forward render path calls restoreMaterialRenderState and then configureBlendGBufferMaterialsForDecalPass with an already-populated map, and Line 936 restores visible from the saved state rather than the current value.

💚 Proposed additional test
+test('restores the saved visibility when the material state is already preserved', () => {
+  const material = new MeshBasicMaterial()
+  const state = new Map([[material as never, {
+    visible: true,
+    depthTest: false,
+    depthWrite: true,
+    polygonOffset: true
+  }]])
+  material.visible = false
+  configureBlendGBufferMaterialsForDecalPass([material], state as never)
+
+  expect(material.visible).toBe(true)
+  expect(material.depthTest).toBe(true)
+  expect(material.depthWrite).toBe(false)
+  expect(material.polygonOffset).toBe(true)
+})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rendering/createMsfsRenderPasses.test.ts` around lines 75 - 85, Add a
test case alongside the existing decal occlusion test that passes a
pre-populated originalMaterialState Map to
configureBlendGBufferMaterialsForDecalPass, using saved material values that
differ from the current material state. Assert that the configuration restores
visible and other render properties from the saved entry, covering the
restoreMaterialRenderState forward-render path rather than the empty-map
fallback.
src/rendering/createAircraftGBuffer.test.ts (1)

29-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the byte total from the attachment types instead of restating the literal.

expect(AIRCRAFT_GBUFFER_BYTES_PER_SAMPLE).toBe(24) only repeats the constant definition. If an attachment changes from UnsignedByteType to HalfFloatType, the layout doubles for that attachment but this test still passes. Compute the sum from renderTarget.textures so the constant and the real layout stay linked.

♻️ Proposed test change
-  expect(AIRCRAFT_GBUFFER_BYTES_PER_SAMPLE).toBe(24)
+  const bytesPerChannel = new Map([[UnsignedByteType, 1], [HalfFloatType, 2]])
+  const measuredBytesPerSample = gBuffer.renderTarget.textures.reduce(
+    (total, texture) => total + (bytesPerChannel.get(texture.type) ?? 0) * 4,
+    0
+  )
+  expect(measuredBytesPerSample).toBe(AIRCRAFT_GBUFFER_BYTES_PER_SAMPLE)
+  expect(AIRCRAFT_GBUFFER_BYTES_PER_SAMPLE).toBe(24)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rendering/createAircraftGBuffer.test.ts` around lines 29 - 42, Update the
byte-size assertion in the createAircraftGBuffer test to calculate the expected
total from gBuffer.renderTarget.textures and each attachment’s
type/bytes-per-channel metadata, rather than asserting the literal 24. Keep the
existing texture order and type assertions unchanged so the
AIRCRAFT_GBUFFER_BYTES_PER_SAMPLE constant is validated against the actual
render-target layout.
src/main.ts (1)

4369-4381: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Group the optional boolean parameters into an options object.

loadAircraftModelDefinitionGltf has one call site. The positional ninth and tenth parameters have defaults and do not identify their purpose at the call site. Use named options for prepareGltfInWorker and buildGBufferWriters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main.ts` around lines 4369 - 4381, Update loadAircraftModelDefinitionGltf
to accept an options object for the optional boolean parameters, and change its
call site to pass named prepareGltfInWorker and buildGBufferWriters values
instead of positional ninth and tenth arguments. Preserve the existing defaults
and behavior, including the exterior-kind mapping for buildGBufferWriters.
src/rendering/createAircraftGBuffer.ts (1)

44-51: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Reuse the MRT probe node

three.js 0.183.0 provides setBlendMode and getBlendMode, so the deferred-path guard is valid. Create one mrt({}) node and reuse it for both checks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rendering/createAircraftGBuffer.ts` around lines 44 - 51, Update
supportsAircraftGBuffer to create a single mrt({}) probe node and reuse it for
both setBlendMode and getBlendMode checks, while preserving the existing WebGPU
backend validation and capability guard.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@NOTES.md`:
- Line 27: The NOTES.md entry is outdated because it describes the removed
depth-mask/framebuffer depth-copy mechanism and misstates WebGPU support. Update
the 2026-07-27 entry to reflect that the deferred G-buffer path replaced the
blend-gbuffer receiver-depth mask approach, and accurately describe the current
createAircraftGBuffer behavior without claiming createMsfsRenderPasses rejects
WebGPU.

In `@src/msfs/gltf/normalizeMsfsMaterials.test.ts`:
- Around line 281-299: Correct the first receiver geometry in the test projects
decals only onto receiver faces matching their authored normal by adding the
missing third coordinate to its vertex data, making it a valid nine-component
triangle at z = 0 that faces away from the decal normal. Keep the sibling
receiver, decal geometry, and assertion unchanged.

In `@src/msfs/gltf/normalizeMsfsMaterials.ts`:
- Around line 1666-1712: Update rebuildConformedDecalGeometry in
src/msfs/gltf/normalizeMsfsMaterials.ts (lines 1666-1712) to select skinIndex
values from the dominant source vertex rather than passing them through
interpolateProjectionAttribute, while preserving Uint16Array when rewriting
skinIndex. Add a test case in src/msfs/gltf/normalizeMsfsMaterials.test.ts
(lines 597-627) covering a skinned decal over an unskinned receiver and
asserting integer skin indices copied from the source decal.
- Around line 3142-3144: Guard the source values before copying or constructing
nodes: only call writerMaterial.color.copy when sourceMaterial.color exists, and
only assign writerMaterial.emissive and invoke uniform for source.emissive when
it is defined. Update the emissive-node construction around
uniform(source.emissive) to skip absent emissive channels, preventing undefined
values from reaching node-type inference.
- Around line 2678-2685: Update the normal-scale handling around
applyMsfsBlendGBufferNodeMaterial and copyMsfsGBufferWriterInputs so the source
material’s normalScale remains unchanged for forward fallback decals. Clone the
aliased normalScale for the G-buffer writer before applying the full-strength
normal blend scale, while preserving the existing normalScale values and
blend-factor behavior elsewhere.

In `@src/rendering/createMsfsRenderPasses.ts`:
- Around line 99-102: The deferred capability gate in hasBlendGBufferMaterials
must exclude objects where isInteriorModelObject returns true, matching refresh
and the exterior-only G-buffer writer contract. In
src/rendering/createMsfsRenderPasses.ts:99-102, apply that filter; in
src/rendering/createMsfsRenderPasses.ts:145, defer createAircraftGBuffer until
refresh confirms an exterior decal and receiver, and add
MsfsRenderPasses.dispose to call gBuffer.dispose(). Make no change at
src/main.ts:4379-4379, which only documents the contract.
- Around line 408-414: Update the deferred base-pass setup around
setMeshMaterials and hideMaterials to also hide the meshes tracked in
forwardSourceMaterials, preventing forward-fallback decals from rendering with
their real materials before the forward decal pass. Keep the existing
restoreMeshMaterials(forwardSourceMaterials) call before that pass so those
materials are restored for the intended single render.
- Around line 582-591: Update syncResolveBatch to return whether batch
synchronization succeeded; preserve the existing matrix/visibility updates on
success and return false when the source or sharing validation fails. At the
call site that processes selected batches, detect a failed syncResolveBatch
result and apply resolve materials to that batch’s individual members so they
use the per-mesh resolve path instead of being omitted.

---

Nitpick comments:
In `@src/main.ts`:
- Around line 4369-4381: Update loadAircraftModelDefinitionGltf to accept an
options object for the optional boolean parameters, and change its call site to
pass named prepareGltfInWorker and buildGBufferWriters values instead of
positional ninth and tenth arguments. Preserve the existing defaults and
behavior, including the exterior-kind mapping for buildGBufferWriters.

In `@src/msfs/gltf/normalizeMsfsMaterials.test.ts`:
- Around line 140-147: Update the split-node handling around
evaluateCoverageNode so multi-component or otherwise unsupported node.components
values are rejected explicitly before indexing; validate that the swizzle
resolves to a single valid xyzw component, and throw a clear error instead of
returning undefined and allowing NaN downstream.
- Around line 597-627: The test around normalizeMsfsMaterials should also cover
an unskinned receiver with a skinned decal. Add a case where the receiver lacks
skinIndex and skinWeight while the decal has those attributes, then assert the
decal does not retain or generically interpolate its skin influences after
rebuildConformedDecalGeometry. Preserve the existing projected normal and
tangent assertions.
- Around line 189-193: Update the assertions in the affected
normalizeMsfsMaterials tests to assert values directly rather than comparing
boolean expressions with toBe(true). Replace count equality checks with direct
equality assertions and tolerance checks with toBeCloseTo or toBeLessThan,
preserving the existing expected values and thresholds so failures report the
actual operands.
- Around line 404-432: The existing G-buffer writer test lacks coverage for
independent ORM blend factors. Extend the test in the `builds G-buffer writers
only for node-compatible materials` case, or add a focused case, using a decal
material whose `ASOBO_material_blend_gbuffer` sets different roughness and
metallic factors (for example 0.5 and 1), and assert that
`getMsfsGBufferWriter(...)` returns null.

In `@src/msfs/gltf/normalizeMsfsMaterials.ts`:
- Around line 1093-1105: Clone the normal retrieved from projectionNormals
before any mutation in the triangle projection flow. Update the projectionNormal
initialization near sourceNormal so both mapped and fallback normals are copied,
preserving the existing negation and cross-product behavior without mutating
stored Vector3 instances.

In `@src/rendering/createAircraftGBuffer.test.ts`:
- Around line 29-42: Update the byte-size assertion in the createAircraftGBuffer
test to calculate the expected total from gBuffer.renderTarget.textures and each
attachment’s type/bytes-per-channel metadata, rather than asserting the literal
24. Keep the existing texture order and type assertions unchanged so the
AIRCRAFT_GBUFFER_BYTES_PER_SAMPLE constant is validated against the actual
render-target layout.

In `@src/rendering/createAircraftGBuffer.ts`:
- Around line 44-51: Update supportsAircraftGBuffer to create a single mrt({})
probe node and reuse it for both setBlendMode and getBlendMode checks, while
preserving the existing WebGPU backend validation and capability guard.

In `@src/rendering/createMsfsRenderPasses.test.ts`:
- Around line 88-96: Extend the test for
canWriteReceiverGBufferFromProjectedGeometry to cover rejection when
transparent, alphaHash, alphaTest, or vertexColors have non-default boolean or
numeric values, while retaining the existing map and normalNode checks. Update
the normalNode assignment to use the established as never cast style, matching
createAircraftGBuffer.test.ts.
- Around line 75-85: Add a test case alongside the existing decal occlusion test
that passes a pre-populated originalMaterialState Map to
configureBlendGBufferMaterialsForDecalPass, using saved material values that
differ from the current material state. Assert that the configuration restores
visible and other render properties from the saved entry, covering the
restoreMaterialRenderState forward-render path rather than the empty-map
fallback.

In `@src/rendering/createMsfsRenderPasses.ts`:
- Around line 633-652: Update the material eligibility predicate to reject
materials with a non-null lightMap, alongside aoMap and the other UV-dependent
map checks. Keep the existing acceptance behavior unchanged for materials
without lightMap.
- Around line 449-453: Resolve the redundant
configureDeferredDecalWriterDepthTest calls around the decal G-buffer pass and
its finally block. Since the helper only assigns depthTest = true, either add
the missing disable branch if the pass requires it, or remove the duplicate call
and rename the helper to reflect the invariant, preferably setting writer
material depth testing once during creation.
- Around line 397-403: The deferred render path should avoid per-frame scratch
allocations and redundant material lookups. In
src/rendering/createMsfsRenderPasses.ts:397-403, hoist the Color scratch object
to module scope beside drawingBufferSize, move originalMaterialState into the
render closure, and clear it at the start of render. In
src/rendering/createMsfsRenderPasses.ts:734-743, update the material-state
helper to iterate sourceMaterials entries once, reuse each entry’s material
value, and handle array materials without allocating a new array on every call.
- Around line 60-71: Update createMsfsRenderPasses to use
selectMsfsDecalRenderPath for its decal render-path decision, removing the
duplicate inline logic and keeping production behavior aligned with the tested
helper; alternatively remove the unused helper and update its tests if the
factory’s inline decision is the intended source of truth.
- Around line 539-563: Update syncResolveBatch to call updateMatrix() once for
each candidate mesh before invoking canShareResolveBatch, then remove the
transform updates from canShareResolveBatch. Keep canShareResolveBatch
side-effect-free while retaining its existing matrix and layer comparisons.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0fa769bf-1a71-4cc3-a19c-a96916aa7f4e

📥 Commits

Reviewing files that changed from the base of the PR and between ff14cac and 29b6f85.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • NOTES.md
  • package.json
  • src/main.ts
  • src/msfs/gltf/normalizeMsfsMaterials.test.ts
  • src/msfs/gltf/normalizeMsfsMaterials.ts
  • src/rendering/createAircraftGBuffer.test.ts
  • src/rendering/createAircraftGBuffer.ts
  • src/rendering/createMsfsRenderPasses.test.ts
  • src/rendering/createMsfsRenderPasses.ts

Comment thread NOTES.md

## Material And Geometry Findings

- 2026-07-27: Correct the earlier WebGPU depth-copy assumption: WebGPU is not fundamentally missing framebuffer/depth copying, and the installed Three.js r182 `WebGPURenderer` / `WebGPUBackend` implements `copyFramebufferToTexture`. The current blend-gbuffer receiver-depth mask is unavailable under WebGPU because `createMsfsRenderPasses.ts` explicitly rejects a WebGPU backend and also depends on the WebGL-only `getDrawingBufferSize` shape. A generic fix should validate destination depth format, physical dimensions, and multisampled-depth compatibility under WebGPU; if the canvas depth attachment cannot be copied into the sampled single-sample depth texture, use an explicit sampleable base-pass depth attachment rather than an aircraft-specific offset.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This note contradicts the code that lands in the same PR.

The entry states that createMsfsRenderPasses.ts "explicitly rejects a WebGPU backend and also depends on the WebGL-only getDrawingBufferSize shape". The new supportsAircraftGBuffer in src/rendering/createAircraftGBuffer.ts does the opposite: Line 47 requires isWebGPUBackend === true, and Line 81 calls renderer.getDrawingBufferSize on a WebGPURenderer.

The entry also describes the "blend-gbuffer receiver-depth mask" as the current mechanism. This PR removes the depth-mask and framebuffer depth-copy path.

Add the outcome to the entry so a future reader does not act on the superseded description. State that the deferred G-buffer path replaced the depth-mask approach.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NOTES.md` at line 27, The NOTES.md entry is outdated because it describes the
removed depth-mask/framebuffer depth-copy mechanism and misstates WebGPU
support. Update the 2026-07-27 entry to reflect that the deferred G-buffer path
replaced the blend-gbuffer receiver-depth mask approach, and accurately describe
the current createAircraftGBuffer behavior without claiming
createMsfsRenderPasses rejects WebGPU.

Comment on lines +281 to +299
test('projects decals only onto receiver faces matching their authored normal', async () => {
const root = new Group()
const parent = new Group()
parent.add(new Mesh(triangleGeometry([0, 0, 0, 0.2, 0, 0, 0.2, 0]), new MeshBasicMaterial()))
parent.add(
new Mesh(
triangleGeometry([0, 0, -0.1, 0, 0.2, -0.1, 0.2, 0, -0.1]),
new MeshBasicMaterial()
)
)
const decalGeometry = triangleGeometry([0, 0, 0.01, 0, 0.2, 0.01, 0.2, 0, 0.01])
const decal = new Mesh(decalGeometry, blendGBufferMaterial())
parent.add(decal)
root.add(parent)

await normalizeMsfsMaterials({ scene: root } as GLTF)

expect(Math.abs(decal.geometry.getAttribute('position').getZ(0) + 0.1) < 1e-6).toBe(true)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The first receiver geometry has 8 position components, so the test does not verify normal-based face selection.

Line 284 passes [0, 0, 0, 0.2, 0, 0, 0.2, 0]. That is 8 numbers with itemSize 3, which yields a fractional vertex count and a malformed attribute. The sibling receiver at Line 287 and the decal at Line 291 both use 9 numbers.

Because the z = 0 receiver is malformed, buildDecalProjectionTriangles cannot produce a usable triangle for it. The assertion at Line 298 then passes only because the z = -0.1 receiver is the sole candidate. The test does not exercise the authored-normal orientation filter it names.

Add the missing component so the z = 0 receiver is a valid triangle that faces away from the decal normal.

💚 Proposed fix
-  parent.add(new Mesh(triangleGeometry([0, 0, 0, 0.2, 0, 0, 0.2, 0]), new MeshBasicMaterial()))
+  parent.add(
+    new Mesh(
+      triangleGeometry([0, 0, 0, 0.2, 0, 0, 0, 0.2, 0]),
+      new MeshBasicMaterial()
+    )
+  )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('projects decals only onto receiver faces matching their authored normal', async () => {
const root = new Group()
const parent = new Group()
parent.add(new Mesh(triangleGeometry([0, 0, 0, 0.2, 0, 0, 0.2, 0]), new MeshBasicMaterial()))
parent.add(
new Mesh(
triangleGeometry([0, 0, -0.1, 0, 0.2, -0.1, 0.2, 0, -0.1]),
new MeshBasicMaterial()
)
)
const decalGeometry = triangleGeometry([0, 0, 0.01, 0, 0.2, 0.01, 0.2, 0, 0.01])
const decal = new Mesh(decalGeometry, blendGBufferMaterial())
parent.add(decal)
root.add(parent)
await normalizeMsfsMaterials({ scene: root } as GLTF)
expect(Math.abs(decal.geometry.getAttribute('position').getZ(0) + 0.1) < 1e-6).toBe(true)
})
test('projects decals only onto receiver faces matching their authored normal', async () => {
const root = new Group()
const parent = new Group()
parent.add(
new Mesh(
triangleGeometry([0, 0, 0, 0.2, 0, 0, 0, 0.2, 0]),
new MeshBasicMaterial()
)
)
parent.add(
new Mesh(
triangleGeometry([0, 0, -0.1, 0, 0.2, -0.1, 0.2, 0, -0.1]),
new MeshBasicMaterial()
)
)
const decalGeometry = triangleGeometry([0, 0, 0.01, 0, 0.2, 0.01, 0.2, 0, 0.01])
const decal = new Mesh(decalGeometry, blendGBufferMaterial())
parent.add(decal)
root.add(parent)
await normalizeMsfsMaterials({ scene: root } as GLTF)
expect(Math.abs(decal.geometry.getAttribute('position').getZ(0) + 0.1) < 1e-6).toBe(true)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/msfs/gltf/normalizeMsfsMaterials.test.ts` around lines 281 - 299, Correct
the first receiver geometry in the test projects decals only onto receiver faces
matching their authored normal by adding the missing third coordinate to its
vertex data, making it a valid nine-component triangle at z = 0 that faces away
from the decal normal. Keep the sibling receiver, decal geometry, and assertion
unchanged.

Comment on lines +1666 to +1712
for (let component = 0; component < attribute.itemSize; component += 1) {
values.push(interpolateProjectionAttribute(
attribute,
vertex.sourceIndices,
vertex.sourceBarycentric,
component
))
}
}

const influences = interpolateProjectedSkinInfluences(
vertex.receiverTriangle,
vertex.receiverBarycentric
)
hasProjectedSkin ||= influences.length > 0
for (let component = 0; component < 4; component += 1) {
projectedSkinIndices.push(influences[component]?.joint ?? 0)
projectedSkinWeights.push(influences[component]?.weight ?? 0)
}
}

for (const [name, attribute] of sourceAttributes) {
if (
name === 'msfsBlendGBufferDepthAllowance' ||
(hasProjectedSkin && (name === 'skinIndex' || name === 'skinWeight'))
) {
continue
}
const values = outputAttributes.get(name)
if (values != null) {
geometry.setAttribute(
name,
new BufferAttribute(new Float32Array(values), attribute.itemSize)
)
}
}
geometry.deleteAttribute('msfsBlendGBufferDepthAllowance')
if (hasProjectedSkin) {
geometry.setAttribute(
'skinIndex',
new BufferAttribute(new Uint16Array(projectedSkinIndices), 4)
)
geometry.setAttribute(
'skinWeight',
new BufferAttribute(new Float32Array(projectedSkinWeights), 4)
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Categorical skinIndex values pass through barycentric interpolation. rebuildConformedDecalGeometry treats every non-special attribute as interpolatable. Joint indices are identifiers, not scalars, so blending joint 2 and joint 7 yields 4.5 and binds the vertex to an unrelated bone. The path is reachable whenever interpolateProjectedSkinInfluences returns an empty list, that is, when the receiver carries no skin influences.

  • src/msfs/gltf/normalizeMsfsMaterials.ts#L1666-L1712: select the dominant source vertex for skinIndex instead of interpolating it, and keep the Uint16Array type when rewriting the attribute at Line 1698.
  • src/msfs/gltf/normalizeMsfsMaterials.test.ts#L597-L627: add a case with a skinned decal over an unskinned receiver, and assert that the resulting skinIndex values are integers taken from the source decal.
📍 Affects 2 files
  • src/msfs/gltf/normalizeMsfsMaterials.ts#L1666-L1712 (this comment)
  • src/msfs/gltf/normalizeMsfsMaterials.test.ts#L597-L627
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/msfs/gltf/normalizeMsfsMaterials.ts` around lines 1666 - 1712, Update
rebuildConformedDecalGeometry in src/msfs/gltf/normalizeMsfsMaterials.ts (lines
1666-1712) to select skinIndex values from the dominant source vertex rather
than passing them through interpolateProjectionAttribute, while preserving
Uint16Array when rewriting skinIndex. Add a test case in
src/msfs/gltf/normalizeMsfsMaterials.test.ts (lines 597-627) covering a skinned
decal over an unskinned receiver and asserting integer skin indices copied from
the source decal.

Comment on lines +2678 to 2685
const normalBlendFactor =
isBlendGBufferMaterial && buildGBufferWriter
? 1
: blendFactors.normal
outputMaterial.normalScale.set(
outputMaterial.normalScale.x * blendFactors.normal,
-Math.abs(outputMaterial.normalScale.y) * blendFactors.normal
outputMaterial.normalScale.x * normalBlendFactor,
-Math.abs(outputMaterial.normalScale.y) * normalBlendFactor
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(normalizeMsfsMaterials|createMsfsRenderPasses)\.(ts|tsx|js|jsx)$|gltf'

printf '%s\n' '--- target outline ---'
ast-grep outline src/msfs/gltf/normalizeMsfsMaterials.ts | head -200

printf '%s\n' '--- target lines ---'
sed -n '2640,2710p' src/msfs/gltf/normalizeMsfsMaterials.ts
sed -n '3000,3070p' src/msfs/gltf/normalizeMsfsMaterials.ts

printf '%s\n' '--- render-pass outline ---'
ast-grep outline src/rendering/createMsfsRenderPasses.ts | head -240

printf '%s\n' '--- relevant render-pass references ---'
rg -n -C 8 'forwardDecals|deferred|decal\.material|msfsGBufferWriter|normalScale|blendFactors' src/rendering/createMsfsRenderPasses.ts src/msfs/gltf/normalizeMsfsMaterials.ts

Repository: jamesx0416/FlightSim

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(normalizeMsfsMaterials|createMsfsRenderPasses)\.(ts|tsx|js|jsx)$|gltf'

printf '%s\n' '--- target outline ---'
ast-grep outline src/msfs/gltf/normalizeMsfsMaterials.ts | head -200

printf '%s\n' '--- target lines ---'
sed -n '2640,2710p' src/msfs/gltf/normalizeMsfsMaterials.ts
sed -n '3000,3070p' src/msfs/gltf/normalizeMsfsMaterials.ts

printf '%s\n' '--- render-pass outline ---'
ast-grep outline src/rendering/createMsfsRenderPasses.ts | head -240

printf '%s\n' '--- relevant render-pass references ---'
rg -n -C 8 'forwardDecals|deferred|decal\.material|msfsGBufferWriter|normalScale|blendFactors' \
  src/rendering/createMsfsRenderPasses.ts \
  src/msfs/gltf/normalizeMsfsMaterials.ts

Repository: jamesx0416/FlightSim

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- normalization entry and call sites ---'
sed -n '340,465p' src/msfs/gltf/normalizeMsfsMaterials.ts
rg -n -C 10 'normalizeMsfsMaterial\(|buildGBufferWriter|createNodeMaterial' \
  src/msfs/gltf/normalizeMsfsMaterials.ts \
  src/msfs/gltf/createMsfsGltfLoader.ts

printf '%s\n' '--- node material and normal construction ---'
sed -n '2890,2965p' src/msfs/gltf/normalizeMsfsMaterials.ts
sed -n '3135,3175p' src/msfs/gltf/normalizeMsfsMaterials.ts
sed -n '3335,3465p' src/msfs/gltf/normalizeMsfsMaterials.ts

printf '%s\n' '--- forward pass material flow ---'
sed -n '220,325p' src/rendering/createMsfsRenderPasses.ts
sed -n '450,510p' src/rendering/createMsfsRenderPasses.ts
sed -n '719,805p' src/rendering/createMsfsRenderPasses.ts
sed -n '915,950p' src/rendering/createMsfsRenderPasses.ts

printf '%s\n' '--- focused tests and references ---'
rg -n -C 8 'normalScale|normalBlend|GBufferWriter|forward|blend.*normal|normal' \
  src/msfs/gltf/normalizeMsfsMaterials.test.ts \
  src/rendering --glob '*test*' || true

Repository: jamesx0416/FlightSim

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- exact blend-node implementation ---'
sed -n '3190,3230p' src/msfs/gltf/normalizeMsfsMaterials.ts

printf '%s\n' '--- exact normal/writer implementation ---'
sed -n '2990,3078p' src/msfs/gltf/normalizeMsfsMaterials.ts
sed -n '3138,3158p' src/msfs/gltf/normalizeMsfsMaterials.ts
sed -n '3350,3442p' src/msfs/gltf/normalizeMsfsMaterials.ts

printf '%s\n' '--- loader options and node factory ---'
rg -n -C 12 'normalizeMsfsMaterials|createNodeMaterial|buildGBufferWriters' \
  src --glob '*.ts' --glob '*.tsx' | head -240

printf '%s\n' '--- focused test fixtures and writer assertions ---'
sed -n '1,90p' src/msfs/gltf/normalizeMsfsMaterials.test.ts
rg -n -C 12 'createMsfsDeferredLightingMaterial|getMsfsGBufferWriter|buildGBuffer|nodeMaterial|normalScale|blendFactors' \
  src/msfs/gltf/normalizeMsfsMaterials.test.ts

printf '%s\n' '--- diff summary and relevant changed lines ---'
git diff --stat
git diff -- src/msfs/gltf/normalizeMsfsMaterials.ts src/rendering/createMsfsRenderPasses.ts | \
  rg -n -C 5 'normalScale|buildGBuffer|forwardSource|msfsGBufferWriter|aircraftG1|blendFactors' | head -260 || true

printf '%s\n' '--- read-only data-flow verifier ---'
python3 - <<'PY'
from pathlib import Path

normalize = Path("src/msfs/gltf/normalizeMsfsMaterials.ts").read_text()
passes = Path("src/rendering/createMsfsRenderPasses.ts").read_text()

checks = {
    "normal_scale_mutates_input_before_replacement": (
        "let outputMaterial = material" in normalize
        and "outputMaterial.normalScale.set(" in normalize
        and normalize.index("outputMaterial.normalScale.set(")
            < normalize.index("const nodeMaterial = ensureNodeMaterial")
    ),
    "writer_uses_source_normal_scale": (
        "createMsfsGBufferNormalNode(source)" in normalize
        and "const normalNode = vec3(resolveMsfsMaterialNode(" in normalize
    ),
    "writer_applies_normal_coverage_factor": (
        "aircraftG1: vec4(normalNode, isDecal ? coverage(blendFactors?.normal ?? 1)" in normalize
    ),
    "forward_path_stores_original_material": (
        "forwardSourceMaterials.set(decal, decal.material)" in passes
        and "restoreMeshMaterials(forwardSourceMaterials)" in passes
    ),
    "forward_path_configures_same_material": (
        "configureBlendGBufferMaterialsForDecalPass(" in passes
        and "forwardColorMaterials.add(material)" in passes
    ),
    "forward_blend_node_does_not_set_normal_factor": (
        "nodeMaterial.colorNode = vec4(" in normalize
        and "normalNode" not in normalize[
            normalize.index("function applyMsfsBlendGBufferNodeMaterial"):
            normalize.index("function getMsfsBlendGBufferForwardOpacityFactor")
        ]
    ),
}

for name, result in checks.items():
    print(f"{name}: {result}")
PY

Repository: jamesx0416/FlightSim

Length of output: 33785


🏁 Script executed:

#!/bin/bash
set -eu

sed -n '3098,3148p' src/msfs/gltf/normalizeMsfsMaterials.ts

python3 - <<'PY'
from pathlib import Path
text = Path("src/msfs/gltf/normalizeMsfsMaterials.ts").read_text()
start = text.index("function copyMsfsGBufferWriterInputs")
end = text.index("function createMsfsGBufferNormalNode")
print(text[start:end])
PY

Repository: jamesx0416/FlightSim

Length of output: 2263


Preserve the blend factor for forward fallback normals

When a deferred relationship is rejected, the forward decal reuses this material. Since applyMsfsBlendGBufferNodeMaterial does not apply blendFactors.normal, the decal renders with full-strength normals. Keep the source material unchanged and apply the full-strength scale only to a cloned normalScale on the G-buffer writer; copyMsfsGBufferWriterInputs currently aliases the source vector.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/msfs/gltf/normalizeMsfsMaterials.ts` around lines 2678 - 2685, Update the
normal-scale handling around applyMsfsBlendGBufferNodeMaterial and
copyMsfsGBufferWriterInputs so the source material’s normalScale remains
unchanged for forward fallback decals. Clone the aliased normalScale for the
G-buffer writer before applying the full-strength normal blend scale, while
preserving the existing normalScale values and blend-factor behavior elsewhere.

Comment on lines +3142 to +3144
writerMaterial.color?.copy?.(sourceMaterial.color)
writerMaterial.emissive = sourceMaterial.emissive
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard emissive before you build the emissive node.

Line 3143 copies sourceMaterial.emissive by direct assignment. A node material converted from a source without an emissive channel, for example a MeshBasicNodeMaterial, has no emissive property. writerMaterial.emissive then becomes undefined.

Line 3186 calls uniform(source.emissive) with that value. uniform cannot infer a node type from undefined.

Line 3142 has the same exposure in reverse: writerMaterial.color.copy(sourceMaterial.color) runs when sourceMaterial.color is undefined, because the optional chain only guards the target side.

🛡️ Proposed fix
-  writerMaterial.color?.copy?.(sourceMaterial.color)
-  writerMaterial.emissive = sourceMaterial.emissive
+  if (sourceMaterial.color != null) {
+    writerMaterial.color?.copy?.(sourceMaterial.color)
+  }
+  if (sourceMaterial.emissive != null) {
+    writerMaterial.emissive = sourceMaterial.emissive
+  }
 function createMsfsGBufferEmissiveNode(material: MsfsMaterial) {
   const source = material as any
+  if (source.emissive == null) {
+    return vec3(0)
+  }
   let emissiveNode = vec3(uniform(source.emissive)).mul(source.emissiveIntensity ?? 1)

Also applies to: 3184-3191

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/msfs/gltf/normalizeMsfsMaterials.ts` around lines 3142 - 3144, Guard the
source values before copying or constructing nodes: only call
writerMaterial.color.copy when sourceMaterial.color exists, and only assign
writerMaterial.emissive and invoke uniform for source.emissive when it is
defined. Update the emissive-node construction around uniform(source.emissive)
to skip absent emissive channels, preventing undefined values from reaching
node-type inference.

Comment on lines +99 to +102
if (!hasBlendGBufferMaterials(root) || !supportsAircraftGBuffer(renderer)) {
return fallback
}
return createDeferredMsfsRenderPasses(renderer, scene, camera, root, fallback)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

The deferred capability gate ignores the interior filter that the rest of the path applies. hasBlendGBufferMaterials(root) traverses every mesh under the aircraft root, including interior meshes. refresh then skips interior meshes at Line 217 through isInteriorModelObject, and src/main.ts builds G-buffer writers only for exterior components. For a model whose blend-G-buffer materials are all in the interior, the gate passes, createAircraftGBuffer allocates four full-screen attachments plus a depth texture, refresh collects no decals, canRenderDeferred stays false, and every frame falls back to the forward renderer. The allocation is never used and AircraftGBuffer.dispose is never called.

  • src/rendering/createMsfsRenderPasses.ts#L99-L102: apply the same interior exclusion in the gate, for example by skipping objects for which isInteriorModelObject returns true inside hasBlendGBufferMaterials.
  • src/rendering/createMsfsRenderPasses.ts#L145-L145: defer createAircraftGBuffer until the first refresh proves at least one exterior decal and receiver exist, and add a dispose method to MsfsRenderPasses that calls gBuffer.dispose().
  • src/main.ts#L4379-L4379: no change needed here; this line documents the exterior-only writer contract that the gate should match.
📍 Affects 2 files
  • src/rendering/createMsfsRenderPasses.ts#L99-L102 (this comment)
  • src/rendering/createMsfsRenderPasses.ts#L145-L145
  • src/main.ts#L4379-L4379
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rendering/createMsfsRenderPasses.ts` around lines 99 - 102, The deferred
capability gate in hasBlendGBufferMaterials must exclude objects where
isInteriorModelObject returns true, matching refresh and the exterior-only
G-buffer writer contract. In src/rendering/createMsfsRenderPasses.ts:99-102,
apply that filter; in src/rendering/createMsfsRenderPasses.ts:145, defer
createAircraftGBuffer until refresh confirms an exterior decal and receiver, and
add MsfsRenderPasses.dispose to call gBuffer.dispose(). Make no change at
src/main.ts:4379-4379, which only documents the contract.

Comment on lines +408 to +414
renderer.setRenderTarget(originalTarget)
renderer.autoClear = true
camera.layers.mask = originalCameraLayerMask
setMeshMaterials(sourceMaterials, hiddenMaterial)
setMeshMaterialsFor(receivers, sourceMaterials)
hideMaterials(transparentSceneMaterials, originalMaterialState)
renderer.render(scene, camera)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Forward-fallback decals render twice per frame.

Line 411 hides every mesh tracked in sourceMaterials, and Line 412 restores the receivers. The meshes tracked in forwardSourceMaterials are not hidden here, so they render in this base pass with their real transparent color materials.

Lines 475-485 then restore those same meshes and render them again in the forward decal pass. Each forward-fallback decal therefore composites twice into the framebuffer. For alpha-blended or additive decal materials the visible result is a doubled contribution.

The forward-only path avoids this: Line 826 adds the color blend materials to hiddenBlendMaterials, so the forward base pass at Line 872 skips them. The deferred path needs the same exclusion.

🐛 Proposed fix
         setMeshMaterials(sourceMaterials, hiddenMaterial)
+        setMeshMaterials(forwardSourceMaterials, hiddenMaterial)
         setMeshMaterialsFor(receivers, sourceMaterials)
         hideMaterials(transparentSceneMaterials, originalMaterialState)
         renderer.render(scene, camera)

Note that Line 476 already calls restoreMeshMaterials(forwardSourceMaterials) before the forward decal pass, so the added hide is reverted at the right point.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
renderer.setRenderTarget(originalTarget)
renderer.autoClear = true
camera.layers.mask = originalCameraLayerMask
setMeshMaterials(sourceMaterials, hiddenMaterial)
setMeshMaterialsFor(receivers, sourceMaterials)
hideMaterials(transparentSceneMaterials, originalMaterialState)
renderer.render(scene, camera)
renderer.setRenderTarget(originalTarget)
renderer.autoClear = true
camera.layers.mask = originalCameraLayerMask
setMeshMaterials(sourceMaterials, hiddenMaterial)
setMeshMaterials(forwardSourceMaterials, hiddenMaterial)
setMeshMaterialsFor(receivers, sourceMaterials)
hideMaterials(transparentSceneMaterials, originalMaterialState)
renderer.render(scene, camera)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rendering/createMsfsRenderPasses.ts` around lines 408 - 414, Update the
deferred base-pass setup around setMeshMaterials and hideMaterials to also hide
the meshes tracked in forwardSourceMaterials, preventing forward-fallback decals
from rendering with their real materials before the forward decal pass. Keep the
existing restoreMeshMaterials(forwardSourceMaterials) call before that pass so
those materials are restored for the intended single render.

Comment on lines +582 to +591
function syncResolveBatch(batch: ResolveBatch): void {
const source = batch.members[0]
if (source == null || !batch.members.every(member => canShareResolveBatch(source, member))) {
return
}
source.updateMatrix()
batch.mesh.matrix.copy(source.matrix)
batch.mesh.matrixWorldNeedsUpdate = true
batch.mesh.visible = true
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

A failed batch sync silently drops the decals from the resolve pass.

At Line 462 setMeshMaterialsFor(selectedBatches.unbatched, resolveMaterials) assigns resolve materials only to the unbatched decals. Every member of a selected batch is excluded from unbatched.

Line 464 then calls syncResolveBatch. If the guard at Line 584 fails, the function returns early and batch.mesh.visible stays false. The batch members have no resolve material assigned and the batch mesh does not draw. Those decals wrote into the G-buffer but never resolve, so they disappear for that frame.

Return a success flag and fall back to per-mesh resolve for the members.

🐛 Proposed fix
-function syncResolveBatch(batch: ResolveBatch): void {
+function syncResolveBatch(batch: ResolveBatch): boolean {
   const source = batch.members[0]
   if (source == null || !batch.members.every(member => canShareResolveBatch(source, member))) {
-    return
+    return false
   }
   source.updateMatrix()
   batch.mesh.matrix.copy(source.matrix)
   batch.mesh.matrixWorldNeedsUpdate = true
   batch.mesh.visible = true
+  return true
 }

Then handle the failure at the call site:

         setMeshMaterialsFor(selectedBatches.unbatched, resolveMaterials)
         for (const batchIndex of selectedBatches.batchIndices) {
-          syncResolveBatch(resolveBatches[batchIndex])
+          const batch = resolveBatches[batchIndex]
+          if (!syncResolveBatch(batch)) {
+            setMeshMaterialsFor(batch.members, resolveMaterials)
+          }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rendering/createMsfsRenderPasses.ts` around lines 582 - 591, Update
syncResolveBatch to return whether batch synchronization succeeded; preserve the
existing matrix/visibility updates on success and return false when the source
or sharing validation fails. At the call site that processes selected batches,
detect a failed syncResolveBatch result and apply resolve materials to that
batch’s individual members so they use the per-mesh resolve path instead of
being omitted.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant