Skip to content

fix(dx12): compile with DXC so hardware ray query is actually reachable#91

Merged
proggeramlug merged 17 commits into
mainfrom
fix/dx12-dxc-ray-query
Jul 13, 2026
Merged

fix(dx12): compile with DXC so hardware ray query is actually reachable#91
proggeramlug merged 17 commits into
mainfrom
fix/dx12-dxc-ray-query

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

ray_query=false in the boot line on every Windows machine. Lumen has been silently running its software trace. The GPU was never the problem.

Cause

wgpu's DX12 backend reports the adapter's shader model as min(device, compiler). Its fallback compiler is FXC, which caps that at 5.1 — and EXPERIMENTAL_RAY_QUERY is gated on shader model ≥ 6.5 (wgpu-hal dx12/adapter.rs, supports_ray_tracing = RaytracingTier 1.1 and SM 6.5).

So with FXC, hardware ray query cannot be exposed on DX12 on any GPU, however capable. wgpu's default (Dx12Compiler::Auto) looks for DXC on PATH, doesn't find it, and quietly falls back to FXC.

Fix

  • Point the DX12 backend at DXC (DynamicDxc).
  • tools/fetch-dxc.ps1 copies dxcompiler.dll + dxil.dll from the Windows SDK next to the binary. (static-dxc would avoid the DLLs but needs MSVC's ATL, which isn't in a default toolchain install.) If the DLLs are missing, wgpu falls back to FXC by itself — we lose HW ray query exactly as before, and nothing else breaks.
  • Pick the adapter deliberately. We took whatever request_adapter handed back and never checked whether another candidate could trace rays. Every surface-capable adapter is now logged with its ray-query support, and one that supports it is preferred. BLOOM_FORCE_SW_GI still forces the software path.

Measured (Radeon 760M, RDNA3, DX12)

before after
boot line ray_query=false ray_query=true
GI backend sdf-clipmap hw-ray-query

Indirect light is visibly contributing for the first time on this hardware.

Consequences for EN-023

EN-023 says "the dev box's Radeon 760M reports none" and builds its priority on that. That premise is wrong — the ticket is annotated. The software path's object-space-AABB bug is still real for adapters that genuinely lack RT (most Android, web permanently), but it is no longer what stands between this hardware and coloured bounce, and "disable SSGI on SW adapters" is no longer needed on Windows.

Not done

The frame-time cost of the HW path versus the SW fallback is unmeasured. That needs the perf protocol run on a fight, not the title screen (per the round-3 lesson). Worth doing before this is relied on — the escape hatch is BLOOM_FORCE_SW_GI=1.

Summary by CodeRabbit

  • New Features

    • Added particle effects and surface decals with configurable styles, lifetimes, and instance rendering.
    • Added animation blending, layered animation, root motion, joint queries, and physics-driven ragdolls.
    • Added foliage wind, cloud shadows, output scaling, and improved shadow rendering.
    • Added audio buses, ducking, low-pass filtering, reverb, and gamepad rumble controls.
    • Added texture-array creation, process launching, and expanded model and VFX APIs.
  • Bug Fixes

    • Preserved linked texture arrays when updating material parameters.
    • Improved world and prefab saving to prevent data corruption.
    • Fixed terrain preview handling for incomplete layer data.
  • Documentation

    • Updated shipped-ticket statuses and expanded the feature backlog.

Ralph Kuepper added 17 commits July 12, 2026 10:16
…ckets

The AAA gap audit found the renderer is past diminishing returns while the
things a player actually *feels* — motion, impacts, marks on the world — had
no engine support at all. These are the four systems that gate the game-side
feel work.

EN-028 animation mixer (models.rs)
  Single-clip sampling made every transition pop, forced a character to stop
  moving in order to attack, and threw away authored locomotion arcs. Adds a
  base track that crossfades (smoothstepped, hemisphere-corrected nlerp), one
  masked layer so an attack can drive the spine while the legs keep walking,
  and opt-in root motion with correct delta across the loop wrap. The engine
  now owns clip time — a crossfade is impossible otherwise, since the outgoing
  clip has to keep advancing.

EN-033 bone sockets (models.rs)
  joint_matrices is skinning-space and useless for attaching props. Keep the
  pre-inverse-bind world transforms too and expose them, so a weapon can ride
  the hand and a muzzle flash can spawn at the real barrel.

EN-026 particles (particles.rs)
  The engine had no particle system; the game faked it with a 16-slot pool.
  Sim is native because the alternative isn't: instance data crosses the FFI
  one float at a time, so 2k particles would be ~24k calls/frame against a
  current budget of ~240. Game/engine traffic is now O(spawn events). SoA pool,
  swap-remove, gravity/drag/floor-bounce, curves over normalized age.

EN-027 decals (decals.rs)
  Oriented sticker quads through the existing instanced cutout path rather than
  a new deferred pass — the honest tradeoff is they can't wrap a corner, which
  no one will see on bullet holes and blood. Normal is packed as two angles to
  fit the instance stride, leaving room for the atlas frame.

Plumbing: InstanceData3D's 3 pad floats were already being uploaded, so they
are now exposed as @location(11) instance_extra at zero cost (no stride change,
no extra bandwidth); adds dynamic (untiled, rewritable) instance buffers, since
the static path reorders instances into XZ tiles — right for grass, wrong for
anything that moves.

44 new FFI symbols across all platforms (incl. real web ports, watchOS stubs);
validate-ffi clean. 8 new unit tests; 108/108 shared tests pass.
…rumble

EN-029 — the mixer was master gain and per-voice gain. That plays sounds; it
does not make a space sound like a place. Adds the three moves a shooter
actually needs:

  - a fixed 3-bus graph (SFX / music / UI) with a duck envelope, because
    "drop the music when the player is hit" is the most-used mix move in the
    genre and it needs music separable from SFX;
  - a Schroeder reverb (4 combs -> 2 allpasses per channel) on a per-sound
    send, which is what gives a weapon its tail and what makes a fight near a
    wall sound indoors;
  - a per-voice one-pole low-pass, which is the occlusion primitive: the game
    raycasts, the mixer muffles. Muffling reads as geometry in a way that
    simply lowering the volume never does.

Routing is a property of the sound, not of each play call (a footstep is
always SFX, a menu blip is always UI), so no call site changes. wet=0 bypasses
the reverb path entirely — it costs nothing until a game asks for it. No
allocation on the audio thread: the send accumulator is preallocated and the
delay lines are fixed.

EN-031 — Windows XInput polling turned out to be fully wired already, with a
canonical button map; the ticket's suspicion that it was never fed by real
hardware was wrong (corrected in the ticket). What was genuinely missing is
rumble, so: shared (low, high, seconds) state that the FFI writes and each
platform's poll drives, with the countdown and change-detection on the
Windows side so we only hit XInputSetState when the commanded level moves.

7 new FFI symbols; validate-ffi clean. 6 new audio tests (bus isolation, duck,
low-pass kills a Nyquist tone, reverb tail, wet=0 bypass); 113/113 pass.
Found by the VFX round, the hard way. This sequence —

    setMaterialTextureArray(m, ALBEDO, arr);   // link the art
    setMaterialParams(m, [...]);               // ...and silently lose it

left the material sampling the 1x1 stub: set_user_params rebuilt the
per-material bind group with default_array_view HARDCODED on bindings 14/15/16
instead of resolving the links the way every other BG-build path does. Nothing
errored and no validation layer complained; the pixels simply never appeared.

It cost an afternoon: the shooter's particles and decals were simulating
perfectly (pools full, instance buffers written, draws dispatched) and
rendering nothing, because every textureSample of the atlas came back empty.
Any game hitting the natural "bind the art, then configure it" order would have
been bitten — SH-009's splat terrain next.

Adds an end-to-end regression test: bind a green array, set params, render,
read the pixel back. Asserting the *link* survives is not enough — the link
always survived; it was the bind group that lost it — so the test renders and
checks the colour. 114/114 shared tests pass.

Also:
- compile_material_instanced_bucket() gains `reads_scene`, without which a soft
  particle's scene_depth_tex sample is not in the pipeline layout and the
  shader fails validation at pipeline-create time (not at write time), which is
  a confusing way to find out.
- bloom_create_texture_array_from_files: build an array by naming the files and
  letting the engine decode them. The byte-array path asks the caller to
  marshal every texel across the FFI — a 6-layer 256^2 array is 1.5M numbers,
  exactly the call shape Perry's array bridge handles worst.
- gamepadRumble() TS binding for EN-031.
Jolt has shipped Ragdoll.cpp in the vendored submodule since day one; what was
missing was any way to reach it. This is that -- but it does NOT use Jolt's
Ragdoll class, which wants a RagdollSettings asset authored alongside the
skeleton. We don't have one, and asking every game to produce one would be the
real cost. Instead the ragdoll is built at runtime from the skeleton we already
have: one capsule per bone, one LIMITED six-DOF joint per articulation. No new
authoring step, and it works for any skinned model a game loads.

THE COORDINATE PROBLEM. Physics runs in world space, skinning in model space.
The bridge is the model transform M the game was drawing with at the moment of
death, and the decision that makes the rest simple is that M is FROZEN at
activation: the corpse's motion belongs to the bodies now, not to the dead
thing's old position. Each bodied joint stores its transform in its body's frame
at activation, and every frame after, the pose is recovered by pushing the
simulated body back through that offset. Joints with no body of their own ride
their nearest bodied ancestor at rest pose -- which is what maxBodies actually
buys: a coarse skeleton that behaves, instead of a fine one that jitters.

Bone selection is breadth-first from the roots, so the budget is spent on the
spine and the limbs -- the things whose motion you read -- and runs out before
it reaches the fingers.

The joint LIMITS are the whole difference between a corpse and a novelty. The
first pass used generous ones and the bodies settled into a puddle; they are now
+/-0.8 rad bend and +/-0.25 twist, because an over-twisted limb is the single
most obviously wrong thing a ragdoll can do. Angular damping does the rest: at
0.75 the limbs stop windmilling after the thing lands.

Adds constraint_six_dof_locked_translation (translation pinned, rotation
limited -- a shoulder does not slide off the torso and an elbow does not bend
backwards) and body_transform, since the existing getters hand back one axis at
a time.

5 FFI symbols; validate-ffi clean. 2 new tests (rigid-inverse round trip,
bone-frame orthonormality); 116/116 shared tests pass.
These were two unrelated noise fields that had never been reconciled, and the
result does not survive looking at:

  - The sky's puffs were fBm over a plane pinned to the CAMERA, so they slid
    along with the player instead of hanging over the world.
  - The ground's "cloud shadow" was a DIFFERENT, much finer field on a different
    noise, drifting ~80x faster (20 m/s against the sky's 0.25).

So the shadow racing across the grass had no cloud above it, and the cloud
overhead cast nothing. Worse, only materials that happened to carry a copy of
the ground function darkened at all -- a shadow could cross a field and ignore
the forest standing in the middle of it, because the built-in scene shader had
no cloud term whatsoever.

common/clouds.wgsl is now the single field, in WORLD space. The deck is a plane
at a given height; a view ray and a sun ray are both intersected with it, and
whatever they hit is the same cloud. Look up at a cloud, and the shadow you are
standing in is its shadow.

THE COUPLING IS THE FEATURE. deck_height and feature_scale are not independent
knobs for "cloud size overhead" and "shadow size underfoot" -- they are the same
cloud seen from two directions. Sky puff size = (deck - eye) x scale. Raising
the deck shrinks the puffs overhead without changing their shadows; lowering the
scale grows the cloud both above and below. Callers who want to tune one WILL
reach for the other, and the doc comment says so.

The shadow multiplies DIRECT sun only. Folding it into ambient as well is what
makes cloud shadows read as flat grey paint instead of shade -- a cloud blocks
the sun, it does not stop the sky from being blue.

Off by default (strength 0): the sky still draws clouds, but silently darkening
every existing game's terrain is not the engine's call. set_cloud_shadows opts
a world in. Drift direction comes from set_wind, so the deck travels the way the
foliage beneath it is leaning.

The shader is prepended verbatim to SCENE_SHADER and PROCEDURAL_SKY_SHADER_WGSL
(raw consts, no preprocessor) and #include-able by materials -- one file either
way, so they cannot drift apart again. Sky uniforms grow the camera position
(packed into the spare .w lanes of the basis vectors -- a sky at infinity never
needed it; a world-anchored deck does) and the cloud params.

1 FFI symbol; validate-ffi clean (6 pre-existing watchos/web gaps unchanged).
116/116 shared tests pass.
…tly drops casters)

The engine swayed ALPHA-CUT materials only. Leaf cards fluttered; every tree trunk
in every game stood perfectly rigid -- a forest of poles with twitching hair. And
the shadow shaders applied no wind whatsoever, so the leaves moved in the light
while their shadows stayed nailed to the ground.

common/foliage_wind.wgsl is now one field shared by the scene pass and both shadow
shaders. Three layers, because a tree does not move as one thing: trunk bend
(proportional to height SQUARED -- a cantilever; the base is planted and the crown
swings, and this is the motion you actually read from 30 m), branch sway
(proportional to reach out from the trunk axis, phase-offset by azimuth so opposite
limbs do not swing together), and leaf flutter (cutout cards only, fast and small).

WHERE THE WEIGHTS COME FROM. SH-013 planned to author them into vertex colours.
That is right for hand-modelled trees, but COLOR_0 is already spent as an albedo
multiplier -- putting wind weights there would tint every trunk -- and ours are
procedurally generated, so the regions are derivable exactly from where a vertex
sits relative to the model origin. No new attribute, no GLB re-bake, works on any
model the game flags.

The offset is computed in WORLD space and mapped back through the model's inverse
linear part (exact for rotation + uniform scale: M^-1 = M^T / s^2). Displacing
along a local axis instead would let each tree's per-instance yaw rotate the wind
with it, and a stand of trees would bend a dozen different directions. Prev-frame
offset too, so TAA gets a real velocity for a moving leaf instead of 0.

EN-042, found on the way. Swaying shadows need the caster in the DYNAMIC set, and
that set holds 64. The forest is 88 trees x 4 primitives = 352. Enabling it
overflowed the budget and the overflow was dropped WITHOUT A WORD -- and it
measured FASTER (34 -> 40 fps), because what it had actually done was silently
delete every tree shadow and the player's own shadow from under their feet. A perf
win that is really a correctness loss is the worst failure mode there is. Foliage
is now promoted to the dynamic set only while slots remain (24 max), so characters
always keep theirs; the rest of the forest stays rigid, which is invisible. Filed
EN-042 for the real fix.

2 FFI symbols; validate-ffi clean. 116/116 shared tests pass.
…hadow cache

The static-cascade shadow cache -- the perf round's single biggest win
(shadow_pass 7.2 ms -> 0.1-1.7 ms) -- had quietly stopped working. shadow_pass GPU
was back to 6.9 ms and the shooter's title screen had fallen from 50.7 to 33.5 fps.

CAUSE. A cached, non-skinned caster whose transform changed since last frame stayed
in the STATIC set, but its content signature changed -- and a changed signature
invalidates the cascade's cached depth. So every tree, wall and terrain tile in the
world re-rendered into all three cascades, every single frame, because something
small was moving. The cache was working exactly as designed and being defeated by
one bobbing object.

FIX. A caster that moves is DYNAMIC, by definition. Hash each caster's transform,
compare against last frame's, and promote the movers: a dynamic caster draws on top
of the cached static depth and never invalidates it. One draw instead of a thousand.

  shadow_pass GPU 6954 us -> 182 us (38x)
  title screen    33.5 -> 44.7 fps
  shadows verified visually: player, canopy and building shadows all present.

THE TRAP INSIDE THE FIX, and it is worth writing down. The first cut keyed casters
on (model_handle, mesh_idx). The forest is 88 trees sharing THREE model handles --
so all 88 collided on one key, each was compared against some other tree's
transform, every one of them was declared a mover, the whole forest went dynamic,
it overflowed the 64-slot dynamic budget (EN-042), and EVERY SHADOW IN THE GAME
VANISHED. While the fps went up. That is the second time this session a measured
"win" turned out to be a correctness loss wearing a win's clothes, and both times
the tell was the same: the number improved more than the change could possibly
justify. The key now carries the Nth-draw-of-this-handle occurrence index, so
occurrence N is the same tree every frame.

116/116 shared tests pass.
A fragment shader that can `discard` cannot early-Z WRITE: the GPU must run it in
full before it knows whether the pixel survives. The scene shader discards
(alpha-cutout foliage), so an 88-tree forest of overlapping leaf cards shaded the
entire 5-target MRT several layers deep and threw most of it away. Measured: the
forest alone was 5.6 ms of a 7.4 ms main_hdr_pass, and simply not drawing it took
the title screen from 46.7 fps to the 60 fps vsync cap.

A depth-only prepass now runs the same cached-model draws first -- same vertex
stage, so the foliage wind displaces identically and the depths agree; alpha cutout
honoured, so the cards keep their real silhouette -- and the main pass draws them
through a pipeline with depth writes OFF and an Equal test.

TAKING THE WRITES AWAY IS THE WHOLE POINT. Priming depth alone did nothing: the
prepass cost 1.4 ms and saved exactly zero, because with depth writes still enabled
the discarding shader forced late-Z and nothing was rejected. Drop the writes (the
prepass already stored the exact depth) and the hardware early-Z rejects the
occluded leaves before the shader ever runs.

  main_hdr_pass  7.43 ms -> 2.14 ms
  depth_prepass  --      -> 1.37 ms
  title screen   33.5    -> 56.6 fps
  gameplay       ~33     -> ~40 fps

THE BUG THIS UNCOVERED. The sky pipeline was depth_write: true, depth_compare:
Always, and the sky draws FIRST inside the HDR pass -- so it stamped depth = 1.0
across the whole screen. That had always been harmless, because the buffer had just
been cleared to 1.0 anyway. It became instantly destructive the moment something
wrote real geometry depth before it: the sky wiped the lot, the Equal test failed
everywhere, and the entire forest and the player VANISHED while the profiler
reported a beautiful 60 fps. Third time this session that a "win" was a correctness
loss; third time a screenshot caught it. The sky never needed that write.

@invariant on the scene VS position is also in place -- genuinely required for an
Equal depth test across two pipelines -- though it was not the cause here.

116/116 shared tests pass. Combat verified: enemies, player, shadows, VFX all render.
…layer's shadow

SHADOW_MAX_DYNAMIC was 64, and the overflow was dropped in QUEUE ORDER, silently.
Fine while "dynamic" meant a handful of characters. A trap the moment a forest could
go dynamic -- 88 trees x 4 primitives = 352 casters -- and it cost this project twice
in a single session:

  - enabling swaying foliage shadows measured 34 -> 40 fps, because it had deleted
    every tree shadow AND the player's own shadow from under their feet;
  - the first cut of EN-043 measured 42 fps, for exactly the same reason.

Both looked like wins. Neither was. A budget that silently drops whatever happened to
be queued last is a landmine, and the fact that it makes the frame rate go UP is what
makes it dangerous.

Fixed two ways. The budget is 256 (of 1024 slots per cascade). And the drop is now
RANKED rather than accidental: characters first -- the shadow a player actually looks
at -- then other movers, then foliage, because a swaying canopy shadow is soft and
dappled and the most forgiving thing in the frame. If a shadow has to be lost, it is
a chosen one.

Also reconciles the ticket doc: EN-025/026/027/028/029/030/031 all shipped this round
but still read as open. EN-012 and EN-015 annotated with what measurement actually
showed (the shooter declined the foliage shading model because its V1 transmission
drops the dot(-N,L) gate; imposters were deprioritised because the forest turned out
to be overdraw, not geometry).

116/116 shared tests pass. Player and canopy shadows verified present.
… camera

shadow_pass GPU was 0.12 ms on the title screen and 3.2 ms in a moving fight. A 27x
gap that nobody had looked at -- because the cache had been MEASURED on the title
screen.

CAUSE. A cascade keeps its cached static depth only while its VP is unchanged.
compute_cascade_vps has exactly the machinery for that: an `accepted_fit` inflated by
REFIT_SLACK, so a cascade keeps its VP while the camera travels within the slack. It
was gated on `c > 0`. Cascade 0 -- the NEAR cascade, the one holding the player and
everything they are standing next to -- re-fit every frame. Its VP therefore changed
on every frame the camera moved, which is all of gameplay, and every static caster in
it re-rendered every frame.

FIX. Cascade 0 gets the slack too. It costs ~15% of near-field shadow resolution and
buys a cache that survives ~15 frames of walking instead of zero.

  shadow_pass GPU (combat)  3.58 ms -> 0.53 ms
  gameplay                  42-44   -> 53-56 fps

Near-field quality verified by screenshot: the player's own shadow is still crisp.

ALSO, EN-043 follow-up: the caster identity has to be ORDER-INDEPENDENT. The first
version keyed on "the Nth draw of this model handle", which was fine until the game
started drawing its forest FRONT-TO-BACK -- the sort order changes as the camera
moves, so occurrence N became a different tree every frame, dozens of stationary
trees were misread as movers, and the dynamic caster set blew past 32 in combat. Two
of my own changes interacting. The key is now one hash of identity AND transform,
tested for membership in last frame's set: "was this exact caster, at this exact
transform, here last frame?" A set test does not care about draw order.

A LESSON ABOUT BENCHMARKS. The cache was landed, measured and celebrated on a screen
where the camera does not move -- the one condition under which its central
assumption always holds. Measure the thing you actually ship.

116/116 shared tests pass.
…never be

set_render_scale shrinks the G-buffer and everything drawn at render resolution, then
TSR upscales to the swapchain. It does NOTHING for the cost of that upscale, or for
the final composite -- and on a 4K display those two passes were measured at 3.10 ms
+ 2.40 ms, about a third of the entire frame. No amount of render-scale would touch
them, which is why the frame had a floor it would not go below.

set_output_scale(s) configures the SWAPCHAIN at s x the window's real size and lets
the presentation engine stretch it back up. It is the only knob that reaches that
fixed tail.

  output 1.0 (native 4K):  taa 3.06 ms + composite 2.37 ms  -> ~53 fps gameplay
  output 0.8:              taa 1.46 ms + composite 1.04 ms  -> LOCKED 60.0 fps
  output 0.6:              taa 0.28 ms + composite 0.22 ms  -> 60 (capped)

At 0.8 the worst frame also falls from 36 ms to 17.9 ms -- the spikes go with it.

Defaults to 1.0, so no existing game changes behaviour. The renderer remembers the
window's native size, so the scale can be changed at runtime without waiting for the
platform to tell it about a resize.

This one is meant to be handed to players. At 4K it is the difference between a
locked frame rate and a sharp image, and which of those somebody wants is not the
engine's call, and not the game's either.

1 FFI symbol pair; validate-ffi clean (6 pre-existing watchos/web gaps unchanged).
116/116 shared tests pass. Verified: the swapchain stretches to the full window with
no letterbox and no crop.
…cess

THE EDITOR COULD NOT SAVE. Saving a world emptied it.

saveWorld used JSON.stringify(world, null, 2). On Perry 0.5.x that CORRUPTS a large
object graph that came from JSON.parse. Minimal repro, no engine code involved:

  const text = readFile('assets/worlds/arena_02.world.json');  // 324 KB, writes back fine
  const o    = JSON.parse(text);                                // fine
  const re   = JSON.stringify(o, null, 2);
  // -> `re` contains 5,296 characters above U+00FF, in a document whose source is
  //    almost pure ASCII. It is garbage.

The corruption is invisible from TypeScript. It only surfaces at the FFI boundary:
str_from_header fails its UTF-8 check, returns "", and bloom_write_file then writes
a ZERO-BYTE FILE AND RETURNS SUCCESS. The editor's save path deleted the file and
told you it had saved it. Every world it ever "saved" was destroyed by saving.

Ruled out by probe, one at a time: size (a FRESH 1 MB world-shaped object stringifies
and writes perfectly), floats, nulls, Record keys, non-ASCII, and even a manual deep
clone of the parsed graph. It is specifically the graph that came from JSON.parse.

TWO FIXES, BOTH NEEDED.

1. src/world/serialize.ts -- a hand-written emitter that walks the schema by LITERAL
   KEY and builds the document by concatenation. This is the same discipline the
   shooter's settings.ts already adopted, for the same reason ("Perry's object
   handling is only trustworthy with literal keys"). saveWorld and savePrefab no
   longer go near JSON.stringify. Being schema-explicit is a feature, not just a
   workaround: a field the serializer has never heard of cannot be silently dropped.

2. try_str_from_header -- an FFI string that fails ABI validation is no longer
   quietly replaced with "". bloom_write_file now FAILS rather than writing an empty
   file and claiming success. An empty string and a failed string are not the same
   thing, and an FFI that PERSISTS its input has to know which one it is holding.
   This is the part that turned a Perry bug into silent data loss.

Verified: loadWorld -> saveWorld -> loadWorld on arena_02 round-trips 168 entities,
5 lights, 1 water volume, 16,384 terrain heights and the world name, with field-level
spot checks matching. 204,270 bytes written where it used to write 0.

116/116 shared tests pass; editor 87/87.
`import { spawn } from 'child_process'` COMPILES on Perry and then does nothing at
all: undefined pid, no process. So no Bloom tool could run another program, and
play-in-editor -- save the level, run the game on it -- had nowhere to go.

launchProcess(cmd, args[], cwd) goes through std::process::Command, fully detached
(never waited on, stdio to null). A GUI must not block on, or die with, the thing it
launched: close the game, and you are back in the editor with your undo history.

TWO TRAPS, BOTH HIT ON THE WAY.

1. Rust's Command::current_dir sets the CHILD's working directory. It does NOT affect
   how the program is FOUND -- that happens in the parent's context. So launching
   "main.exe" with cwd "<project>" fails with "program not found" while main.exe is
   sitting right there in <project>. A bare command name is now resolved against cwd
   before spawning.

2. Args cross the FFI as a newline-separated string and are re-split into a real
   argv. No shell is involved, which is also why there is nothing to inject into.

Verified end to end: the editor saved arena_02 to a scratch world (204,275 bytes),
resolved .\main.exe against the project dir, and launched the game on it -- pid
16584, rendering the level the editor had just written.

116/116 shared tests. validate-ffi clean (same 6 pre-existing watchos/web gaps).
…/051)

`TerrainLayer.weights` existed in the schema, was written by nothing and read by
nothing. The editor now paints them and the shooter renders them.

- `buildHeightmapMesh` tints vertex colour by the splat weights, so the editor
  gets a paint preview for free (mask colours — coverage, not material).
- `createTerrainLayer` / `quantizeWeight` / `terrainLayerMaskColor`.
- EN-049 `createTextureArrayFromTexels`: a texture array built from DATA. The
  byte-array FFI takes a raw pointer, and Perry cannot pass an array into an i64
  param ("Expected safe integer for native i64 parameter") — so it was uncallable
  and every caller had fallen back to `createTextureArrayFromFiles`, which is
  right for art and useless for a splat map computed at load. Payload goes through
  the mesh scratch buffer as packed u32 texels, the same fix `createMesh` uses.

EN-050 — `clamp` in world/terrain.ts, whose body was a single nested-ternary
return, evaluated to `lo` for EVERY input when called from `quantizeWeight`, while
`sampleHeight` in the same file called the same helper correctly. Every splat
weight quantised to 0 and painted terrain loaded unpainted. Fixed with `if`
statements; pinned by the editor's `testSplatPaintPartition`.

EN-051 — `easeInOutQuad` never receives its argument and returns a constant. Left
broken and ticketed: nothing calls it, and contorting a function around a bug I
cannot explain would only hide it. `easeInOutCubic` is the same shape and is fine,
so the shape is NOT the trigger.

Claude-Session: https://claude.ai/code/session_01V3MihNxaRwMR8GjMPvMkRb
The boot line has been saying `ray_query=false` on every Windows machine, and
Lumen has been silently running its software trace. The GPU was never the
problem.

wgpu's DX12 backend reports the adapter's shader model as min(device, compiler).
Its fallback compiler is FXC, which caps that at 5.1 — and EXPERIMENTAL_RAY_QUERY
is gated on shader model >= 6.5 (wgpu-hal dx12/adapter.rs: `supports_ray_tracing`
requires RaytracingTier 1.1 AND SM 6.5). So with FXC, hardware ray query cannot
be exposed on DX12 on ANY GPU, however capable. wgpu's default compiler setting
(`Auto`) looks for DXC on PATH, does not find it, and falls back to FXC without
saying anything.

Point the DX12 backend at DXC (`DynamicDxc`), and ship a script that copies
`dxcompiler.dll` + `dxil.dll` from the Windows SDK next to the binary.
`static-dxc` would avoid the DLLs but needs MSVC's ATL, which is not in a default
toolchain install. If the DLLs are absent, wgpu falls back to FXC on its own —
we lose HW ray query exactly as before, and nothing else breaks.

Also: pick the adapter deliberately. We took whatever `request_adapter` returned
and never looked at whether another candidate could trace rays. Now every
surface-capable adapter is logged with its ray_query support, and one that
supports it is preferred. BLOOM_FORCE_SW_GI still forces the software path.

Measured on a Radeon 760M (RDNA3, DX12):
  before:  ray_query=false, ssgi trace backend = sdf-clipmap
  after:   ray_query=true,  ssgi trace backend = hw-ray-query

This also undercuts EN-023's premise ("the dev box's 760M reports none"), noted
in the ticket. The software path's object-space-AABB bug is still real for
adapters that genuinely lack RT (Android, web), but it is no longer what stands
between this hardware and coloured bounce.

NOT YET MEASURED: the frame-time cost of the HW path versus the SW fallback.
That needs the perf protocol on a fight, not the title screen.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

Failed to post review comments.

GitHub was unavailable or timed out while CodeRabbit was posting the review. Please request a new review later if the pull request still needs one. This happened while posting 11 inline comments. Use @coderabbitai full review to retry the review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e174a593-bf46-40ca-a450-f6859d4d7ad6

📥 Commits

Reviewing files that changed from the base of the PR and between ebf816f and c1b2980.

📒 Files selected for processing (54)
  • docs/tickets.md
  • native/shared/shaders/common/clouds.wgsl
  • native/shared/shaders/common/foliage_wind.wgsl
  • native/shared/shaders/material_abi.wgsl
  • native/shared/src/audio/mod.rs
  • native/shared/src/audio/render.rs
  • native/shared/src/decals.rs
  • native/shared/src/engine.rs
  • native/shared/src/ffi_core/assets.rs
  • native/shared/src/ffi_core/audio_ffi.rs
  • native/shared/src/ffi_core/game_loop.rs
  • native/shared/src/ffi_core/input.rs
  • native/shared/src/ffi_core/mod.rs
  • native/shared/src/ffi_core/models.rs
  • native/shared/src/ffi_core/ragdoll_ffi.rs
  • native/shared/src/ffi_core/vfx.rs
  • native/shared/src/ffi_core/visual.rs
  • native/shared/src/input.rs
  • native/shared/src/lib.rs
  • native/shared/src/models.rs
  • native/shared/src/particles.rs
  • native/shared/src/physics_jolt.rs
  • native/shared/src/ragdoll.rs
  • native/shared/src/renderer/material_instancing.rs
  • native/shared/src/renderer/material_pipeline.rs
  • native/shared/src/renderer/material_system.rs
  • native/shared/src/renderer/material_system_tests.rs
  • native/shared/src/renderer/mod.rs
  • native/shared/src/renderer/model_draw.rs
  • native/shared/src/renderer/scene_pass.rs
  • native/shared/src/renderer/shader_library.rs
  • native/shared/src/renderer/shaders/core.rs
  • native/shared/src/renderer/shaders/env.rs
  • native/shared/src/renderer/shadow_pass.rs
  • native/shared/src/renderer/types.rs
  • native/shared/src/shadows.rs
  • native/shared/src/string_header.rs
  • native/watchos/src/ffi_stubs.rs
  • native/web/src/input_ffi.rs
  • native/web/src/lib.rs
  • native/web/src/material_ffi.rs
  • native/windows/src/lib.rs
  • package.json
  • src/audio/index.ts
  • src/core/index.ts
  • src/index.ts
  • src/math/index.ts
  • src/models/index.ts
  • src/vfx/index.ts
  • src/world/saver.ts
  • src/world/serialize.ts
  • src/world/terrain.ts
  • tools/fetch-dxc.ps1
  • tools/validate-ffi.js
⏰ Context from checks skipped due to timeout. (7)
  • GitHub Check: shared-tests (windows-latest)
  • GitHub Check: shared-tests (ubuntu-22.04)
  • GitHub Check: build-macos
  • GitHub Check: shared-tests (macos-14)
  • GitHub Check: build-windows
  • GitHub Check: build-linux
  • GitHub Check: build-web
🧰 Additional context used
📓 Path-based instructions (7)
native/shared/**/*.wgsl

📄 CodeRabbit inference engine (CLAUDE.md)

Boot a game or run the golden suite after every WGSL edit because shaders compile at runtime rather than during cargo build.

Files:

  • native/shared/shaders/material_abi.wgsl
  • native/shared/shaders/common/foliage_wind.wgsl
  • native/shared/shaders/common/clouds.wgsl
native/shared/**/*.rs

📄 CodeRabbit inference engine (CLAUDE.md)

native/shared/**/*.rs: Do not return packed text for per-frame parsing; cross numeric values as f64 FFI parameters and pass strings whole for drawing.
When passing TypeScript number[] values to i64 parameters, use the all-f64 scratch-buffer pattern used by bloom_mesh_scratch_* and createMesh.
Run both native and WASM compilation checks for shared code, including cargo check --target wasm32-unknown-unknown --no-default-features --features web.
Run cargo test --release and the golden-image suite before renderer pull requests; regenerate goldens only with BLOOM_UPDATE_GOLDEN=1 and commit only intentionally changed goldens.

Files:

  • native/shared/src/ffi_core/input.rs
  • native/shared/src/input.rs
  • native/shared/src/renderer/material_pipeline.rs
  • native/shared/src/renderer/shader_library.rs
  • native/shared/src/ffi_core/audio_ffi.rs
  • native/shared/src/lib.rs
  • native/shared/src/ffi_core/mod.rs
  • native/shared/src/ffi_core/game_loop.rs
  • native/shared/src/engine.rs
  • native/shared/src/ffi_core/assets.rs
  • native/shared/src/ffi_core/visual.rs
  • native/shared/src/renderer/material_instancing.rs
  • native/shared/src/renderer/model_draw.rs
  • native/shared/src/string_header.rs
  • native/shared/src/renderer/material_system.rs
  • native/shared/src/physics_jolt.rs
  • native/shared/src/particles.rs
  • native/shared/src/renderer/types.rs
  • native/shared/src/ffi_core/models.rs
  • native/shared/src/ffi_core/ragdoll_ffi.rs
  • native/shared/src/decals.rs
  • native/shared/src/renderer/shaders/core.rs
  • native/shared/src/renderer/scene_pass.rs
  • native/shared/src/renderer/material_system_tests.rs
  • native/shared/src/ffi_core/vfx.rs
  • native/shared/src/audio/render.rs
  • native/shared/src/audio/mod.rs
  • native/shared/src/renderer/shadow_pass.rs
  • native/shared/src/ragdoll.rs
  • native/shared/src/renderer/shaders/env.rs
  • native/shared/src/shadows.rs
  • native/shared/src/models.rs
  • native/shared/src/renderer/mod.rs
native/**/*.rs

📄 CodeRabbit inference engine (CLAUDE.md)

native/**/*.rs: Allocate Engine-to-Perry strings with string_header::alloc_perry_string and read Perry-to-engine strings with validated str_from_header; never hand-roll string headers.
Account for panic = "abort" on native targets: Rust panics fast-fail, while Windows access violations are handled by the crash filter and minidump workflow.

Files:

  • native/shared/src/ffi_core/input.rs
  • native/shared/src/input.rs
  • native/shared/src/renderer/material_pipeline.rs
  • native/shared/src/renderer/shader_library.rs
  • native/shared/src/ffi_core/audio_ffi.rs
  • native/shared/src/lib.rs
  • native/shared/src/ffi_core/mod.rs
  • native/web/src/input_ffi.rs
  • native/shared/src/ffi_core/game_loop.rs
  • native/shared/src/engine.rs
  • native/shared/src/ffi_core/assets.rs
  • native/shared/src/ffi_core/visual.rs
  • native/shared/src/renderer/material_instancing.rs
  • native/shared/src/renderer/model_draw.rs
  • native/shared/src/string_header.rs
  • native/shared/src/renderer/material_system.rs
  • native/shared/src/physics_jolt.rs
  • native/shared/src/particles.rs
  • native/shared/src/renderer/types.rs
  • native/windows/src/lib.rs
  • native/shared/src/ffi_core/models.rs
  • native/shared/src/ffi_core/ragdoll_ffi.rs
  • native/web/src/lib.rs
  • native/shared/src/decals.rs
  • native/shared/src/renderer/shaders/core.rs
  • native/shared/src/renderer/scene_pass.rs
  • native/shared/src/renderer/material_system_tests.rs
  • native/shared/src/ffi_core/vfx.rs
  • native/shared/src/audio/render.rs
  • native/web/src/material_ffi.rs
  • native/shared/src/audio/mod.rs
  • native/shared/src/renderer/shadow_pass.rs
  • native/shared/src/ragdoll.rs
  • native/shared/src/renderer/shaders/env.rs
  • native/shared/src/shadows.rs
  • native/shared/src/models.rs
  • native/shared/src/renderer/mod.rs
  • native/watchos/src/ffi_stubs.rs
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Do not use reachable throw statements or implicit object keys in returned objects because engine TypeScript is compiled by Perry; follow docs/perry-quirks.md.

Files:

  • src/index.ts
  • src/math/index.ts
  • src/audio/index.ts
  • src/world/serialize.ts
  • src/world/saver.ts
  • src/vfx/index.ts
  • src/core/index.ts
  • src/world/terrain.ts
  • src/models/index.ts
native/shared/src/renderer/**/*.rs

📄 CodeRabbit inference engine (CLAUDE.md)

native/shared/src/renderer/**/*.rs: Do not benchmark with the profiler enabled because enabling it inserts a blocking per-frame GPU synchronization.
Ensure renderer FFI calls occur after initWindow, because calls before initialization panic.

Files:

  • native/shared/src/renderer/material_pipeline.rs
  • native/shared/src/renderer/shader_library.rs
  • native/shared/src/renderer/material_instancing.rs
  • native/shared/src/renderer/model_draw.rs
  • native/shared/src/renderer/material_system.rs
  • native/shared/src/renderer/types.rs
  • native/shared/src/renderer/shaders/core.rs
  • native/shared/src/renderer/scene_pass.rs
  • native/shared/src/renderer/material_system_tests.rs
  • native/shared/src/renderer/shadow_pass.rs
  • native/shared/src/renderer/shaders/env.rs
  • native/shared/src/renderer/mod.rs
native/web/**/*.rs

📄 CodeRabbit inference engine (CLAUDE.md)

Expose _str variants for string-taking web functions and _bytes variants for functions taking file data.

Files:

  • native/web/src/input_ffi.rs
  • native/web/src/lib.rs
  • native/web/src/material_ffi.rs
package.json

📄 CodeRabbit inference engine (CLAUDE.md)

Declare every new native FFI function in package.json under perry.nativeLibrary.functions; clear the consumer game's .perry-cache/ after manifest changes.

Files:

  • package.json
🪛 Biome (2.5.1)
src/world/serialize.ts

[error] 71-71: This comparison uses the same expression on both sides.

(lint/suspicious/noSelfCompare)

🪛 markdownlint-cli2 (0.22.1)
docs/tickets.md

[warning] 1609-1609: No space after hash on atx style heading

(MD018, no-missing-space-atx)

📝 Walkthrough

Walkthrough

This PR adds runtime particles, decals, animation mixing, ragdolls, audio routing, cloud and foliage rendering, depth-prepass support, new native and TypeScript APIs, platform integrations, custom world serialization, terrain previews, and expanded ticket documentation.

Changes

Engine feature expansion

Layer / File(s) Summary
Particle and decal runtime
native/shared/src/{particles,decals,engine}.rs, native/shared/src/ffi_core/vfx.rs
Adds CPU particle simulation, decal lifetime/ring management, packed instance buffers, and FFI lifecycle controls.
Animation and ragdoll simulation
native/shared/src/models.rs, native/shared/src/ragdoll.rs, native/shared/src/physics_jolt.rs, native/shared/src/ffi_core/{models,ragdoll_ffi}.rs
Adds animation blending, root motion, socket queries, ragdoll physics planning, updates, and release handling.
Cloud, foliage, and depth rendering
native/shared/shaders/*, native/shared/src/renderer/*, native/shared/src/shadows.rs
Adds shared cloud and foliage shaders, cloud shadows, foliage motion, depth prepasses, cached-model rendering, and deterministic shadow-caster budgeting.
Audio routing and DSP
native/shared/src/audio/*, native/shared/src/ffi_core/audio_ffi.rs, src/audio/index.ts
Adds buses, ducking, per-sound routing, low-pass filtering, reverb, and corresponding controls and tests.
Native FFI and platform contracts
native/shared/src/ffi_core/*, native/web/*, native/watchos/*, package.json
Expands native, WebAssembly, watchOS, and manifest surfaces for the new runtime features.
TypeScript API and data fixes
src/{core,models,vfx,world}/*, src/index.ts
Exposes new engine APIs, adds VFX and model helpers, replaces world serialization, and adds terrain layer preview utilities.
Windows GPU and rumble integration
native/windows/src/lib.rs, tools/fetch-dxc.ps1
Loads DXC for DX12 shader compilation, prefers compatible ray-query adapters, and sends XInput rumble updates.
Ticket status and validation records
docs/tickets.md, tools/validate-ffi.js
Updates shipped-ticket records, documents the GI decision and AAA-feel backlog, and expands the documented FFI gap allowlist.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TypeScript
  participant NativeFFI
  participant Engine
  participant Renderer
  participant Audio
  TypeScript->>NativeFFI: invoke feature control
  NativeFFI->>Engine: update runtime state
  Engine->>Renderer: upload simulation or rendering data
  Engine->>Audio: apply routing and DSP commands
  Renderer-->>TypeScript: return handles, counts, or values
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is informative, but it does not follow the required template sections or include the test-plan checklist. Rewrite the PR description using the repository template with Summary, Test plan checkboxes, and Notes for reviewers.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the DXC-based DX12 ray-query fix.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 fix/dx12-dxc-ray-query

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.

@proggeramlug proggeramlug merged commit 95657ea into main Jul 13, 2026
9 of 10 checks passed
@proggeramlug proggeramlug deleted the fix/dx12-dxc-ray-query branch July 13, 2026 13:49
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