Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,8 @@ _compile.log
_gamepid.txt
_run_err.txt
_run_out.txt

# lld /DEBUG output from perry compile --debug-symbols
main.pdb
# perry/lld intermediate files left behind when a relink races a stale handle
*.tmp0
47 changes: 38 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ runnable with `perry compile src/main.ts -o main && ./main`.

## Where things live

- `src/main.ts` — single-file game (~900 LOC). Flat-array state per
Perry convention.
- `src/main.ts` — single-file game (~2,200 LOC). Flat-array state per
Perry convention. Also carries an inline copy of
`grass_instanced.wgsl` (`GRASS_INSTANCED_WGSL`) that MUST be kept in
sync with the on-disk file when either changes (SH-005 will automate
this; the drift bit for real once already).
- `src/input.ts` / `src/player.ts` — small single-purpose modules.
- `src/generated/world.ts` — **generated, do not edit**. Built from
`assets/worlds/arena_02.world.json` by `tools/build-world.ts`. The
Expand All @@ -27,14 +30,17 @@ runnable with `perry compile src/main.ts -o main && ./main`.

## Read before changing things

- **`docs/perry-quirks.md`** — three Perry 0.5.158 bugs we work around.
- **`docs/perry-quirks.md`** — five Perry 0.5.x bugs we work around.
Never add `throw new Error(...)`, never rely on object-shorthand
return values, never `JSON.parse(...).arr.length`. Use flat arrays
and explicit keys. A well-meaning refactor can silently crash this
on startup if these rules are forgotten.
- **`docs/engine-notes.md`** — the engine needs `default = ["jolt"]`
in `native/macos/Cargo.toml` and `"libs": ["c++"]` in
`package.json`'s macOS nativeLibrary target. Not yet upstream.
return values, never `JSON.parse(...).arr.length`, and **never
`split()`/`parseFloat()` an FFI string on a per-frame path** (quirk
#5 / EN-020 — the runtime overreads its own slice allocations; this
crashed the shipped game). Use flat arrays, explicit keys, and
numeric FFIs. A well-meaning refactor can silently crash this on
startup if these rules are forgotten.
- **`docs/engine-notes.md`** — the macOS Jolt/`libc++` link
requirements are landed in the engine repo now; the doc records why
they exist.
- **`docs/asset-pipeline.md`** — the IQE → glTF conversion notes,
especially the Z-up → Y-up rotation scheme. The naive
"conjugate every joint" approach doesn't work; only the root joint
Expand All @@ -57,6 +63,26 @@ bun tools/convert-arena.ts # regenerate textured arena
bun tools/validate-glb.ts <path> # glTF-validator report
```

### Windows specifics (the current dev box)

- `npm` may not be on the PowerShell PATH — run the generator directly:
`bun tools/build-world.ts assets/worlds/arena_02.world.json`.
- Compile with `perry compile src/main.ts -o main --debug-symbols` so
`main.pdb` lands next to the exe — crash reports then symbolize.
- Engine rebuild:
`cargo build --release --manifest-path ..\engine\native\windows\Cargo.toml`
with `INTERPROCEDURAL_OPTIMIZATION=OFF` in the environment. After an
engine-only rebuild, **touch `src/main.ts`** or perry skips the
relink; after any engine `package.json` manifest change, also delete
`.perry-cache/`.
- Crashes self-report: the engine prints
`bloom: FATAL unhandled exception … (main.exe+0x…)` to stderr and
writes a minidump to `tools/.testout/dumps/` (WER LocalDumps is armed
there too). Triage runbook: engine `docs/crash-triage-windows.md`.
- The game runs borderless-fullscreen at native res — test runs are
batch runs (launch with stdout/stderr redirected; the screen is
gone while it runs).

## World pipeline

`assets/worlds/<name>.world.json` is authored by hand (later: by the
Expand All @@ -80,6 +106,9 @@ come from the top-level fields. Adding a new kind = add a bucket in
- Return `{ ok: boolean, error: string }` instead of throwing.
- Prefer hardcoded constants over JSON-loaded config until the Perry
JSON.parse bug is resolved.
- Data crossing the FFI on a hot path is numbers, not delimited
strings (perry-quirks #5). Strings that do cross are drawn whole,
never parsed.

## License

Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Controls:
| 1 / 2 | Switch weapon (rifle / blaster) |
| R | Reload / restart after death |
| Tab | Toggle cursor capture |
| F3 | Profiler overlay (per-pass CPU/GPU µs) |
| F12 | Screenshot to `shooter_<N>.png` |
| Esc | Quit |

Expand All @@ -40,10 +41,11 @@ tyrant. Ammo crates respawn at the four arena corners.
- **Perry** `0.5.158+` on `$PATH`.
- **Bloom engine** at `../engine/` relative to this repo, built at
commit `0137335` (drop debug-green hardcode in `fs_main_3d`) or
later. The engine's `native/macos/Cargo.toml` needs `default =
["jolt"]` and its `package.json` needs `"libs": ["c++"]` on the
macOS target so Jolt physics links cleanly (see
[docs/engine-notes.md](docs/engine-notes.md)).
later. The Jolt/`libc++` link requirements are committed in the
engine repo these days — [docs/engine-notes.md](docs/engine-notes.md)
records why they exist. On Windows, prefer
`perry compile src/main.ts -o main --debug-symbols` so crash
reports symbolize (see `CLAUDE.md`).
- **Bun** for the asset converters and dev scripts.
- **ffmpeg** and macOS's `sips` for audio + texture resizing during
asset conversion.
Expand Down
32 changes: 27 additions & 5 deletions assets/materials/building.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) world_normal: vec3<f32>,
// EN-022 — clip positions for motion vectors.
@location(2) curr_clip: vec4<f32>,
@location(3) prev_clip: vec4<f32>,
};

@vertex
Expand All @@ -28,6 +31,9 @@ fn vs_main(in: VertexInput) -> VsOut {
out.world_pos = world.xyz;
out.world_normal = normalize((draw.model * vec4<f32>(in.normal, 0.0)).xyz);
out.clip_pos = view.view_proj * world;
// EN-022 — static geometry: camera-reprojection motion vector.
out.curr_clip = out.clip_pos;
out.prev_clip = view.prev_view_proj * world;
return out;
}

Expand Down Expand Up @@ -69,10 +75,25 @@ fn fs_main(in: VsOut) -> OpaqueOut {
+ value_noise(sample_uv * bp.knobs.x * 2.5) * 0.3;
let speckled = mix(bp.base.rgb, bp.base.rgb * 1.18, nz);

// Horizontal band lines every `band_period` metres. Use a
// sharp cosine pulse — the higher band.w, the thinner the line.
let band_phase = cos(in.world_pos.y * 6.28318 / bp.knobs.y);
let band_t = pow(max(band_phase, 0.0), bp.band.w * 8.0);
// Horizontal mortar lines every `band_period` metres, analytically
// antialiased. The old form — pow(max(cos(y·f),0), band.w·8) — was a
// razor-sharp procedural pulse with no filtering. Re-evaluated each
// frame at TAA-jittered sample positions on the half-res buffer, its
// hard edges flipped pixels every frame: the reported gray-line
// flicker crawling across the wall (worst up close, where the bands
// are large). Here the line is a distance field antialiased over
// exactly one pixel via fwidth, and it fades toward the wall colour
// once a line packs below the pixel rate — so it stays rock-stable
// at every distance and grazing angle.
let band_coord = in.world_pos.y / bp.knobs.y; // 1 unit per period
let band_dist = abs(fract(band_coord + 0.5) - 0.5) * 2.0; // 0 at a line, 1 between
let band_fw = max(fwidth(band_coord), 1e-5); // pixel footprint (periods)
// Line half-width as a fraction of the period, matched to the old
// pow(band.w·8) thickness.
let band_hw = 0.5 / (bp.band.w * 8.0 + 1.0);
let band_edge = 1.0 - smoothstep(band_hw - band_fw, band_hw + band_fw, band_dist);
let band_cov = clamp(band_hw / band_fw, 0.0, 1.0); // fade when sub-pixel
let band_t = band_edge * band_cov;
let albedo = mix(speckled, bp.band.rgb, band_t * 0.6);

// Lambert vs the engine sun + ambient, with the same cloud-shadow
Expand All @@ -99,7 +120,8 @@ fn fs_main(in: VsOut) -> OpaqueOut {
var out: OpaqueOut;
out.hdr = vec4<f32>(lit, 1.0);
out.material = vec2<f32>(0.0, 0.78); // non-metal, mid-roughness stone
out.velocity = vec2<f32>(0.0, 0.0);
// EN-022 — real motion vectors (see terrain.wgsl).
out.velocity = abi_motion_vector(in.curr_clip, in.prev_clip);
out.albedo = vec4<f32>(albedo, 1.0);
return out;
}
20 changes: 19 additions & 1 deletion assets/materials/grass_instanced.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ struct VsOut {
@location(1) world_normal: vec3<f32>,
@location(2) blade_tint: vec3<f32>,
@location(3) tip_weight: f32,
// EN-022 — clip positions for motion vectors.
@location(4) curr_clip: vec4<f32>,
@location(5) prev_clip: vec4<f32>,
};

@vertex
Expand Down Expand Up @@ -94,6 +97,20 @@ fn vs_main(in: InstancedVertexInput) -> VsOut {
out.clip_pos = view.view_proj * vec4<f32>(world, 1.0);
out.tip_weight = tip;
out.blade_tint = grass.base.rgb * in.instance_tint.rgb;

// EN-022 — previous-frame position: same sway one frame back
// (PerFrame.delta_time), projected by the previous VP. Sub-pixel
// blades finally carry real motion vectors so TAA's motion clamp
// engages (the audit's thin-grass shimmer mechanism).
let t_prev = frame.time - frame.delta_time;
let phase_p = dot(in.instance_pos.xz, frame.wind.xy * 0.6)
+ t_prev * frame.wind.w;
let sway_p = sin(phase_p) * frame.wind.z * tip;
let world_p = rotated
+ vec3<f32>(frame.wind.x, 0.0, frame.wind.y) * sway_p
+ in.instance_pos;
out.curr_clip = out.clip_pos;
out.prev_clip = view.prev_view_proj * vec4<f32>(world_p, 1.0);
return out;
}

Expand Down Expand Up @@ -152,7 +169,8 @@ fn fs_main(in: VsOut) -> OpaqueOut {
var out: OpaqueOut;
out.hdr = vec4<f32>(lit, 1.0);
out.material = vec2<f32>(0.0, 0.92);
out.velocity = vec2<f32>(0.0, 0.0);
// EN-022 — real motion vectors, including per-blade sway.
out.velocity = abi_motion_vector(in.curr_clip, in.prev_clip);
out.albedo = vec4<f32>(albedo, 1.0);
return out;
}
11 changes: 10 additions & 1 deletion assets/materials/terrain.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) world_normal: vec3<f32>,
// EN-022 — clip positions for motion vectors.
@location(2) curr_clip: vec4<f32>,
@location(3) prev_clip: vec4<f32>,
};

@vertex
Expand All @@ -37,6 +40,10 @@ fn vs_main(in: VertexInput) -> VsOut {
out.world_pos = world.xyz;
out.world_normal = normalize((draw.model * vec4<f32>(in.normal, 0.0)).xyz);
out.clip_pos = view.view_proj * world;
// EN-022 — static geometry: previous world == current world, so the
// motion vector is pure camera reprojection.
out.curr_clip = out.clip_pos;
out.prev_clip = view.prev_view_proj * world;
return out;
}

Expand Down Expand Up @@ -117,7 +124,9 @@ fn fs_main(in: VsOut) -> OpaqueOut {
var out: OpaqueOut;
out.hdr = vec4<f32>(lit, 1.0);
out.material = vec2<f32>(0.0, 0.95);
out.velocity = vec2<f32>(0.0, 0.0);
// EN-022 — real motion vectors: lets TAA's motion-adaptive clamp
// engage on the terrain (audit F8's shimmer mechanism).
out.velocity = abi_motion_vector(in.curr_clip, in.prev_clip);
out.albedo = vec4<f32>(final_albedo, 1.0);
return out;
}
65 changes: 54 additions & 11 deletions assets/materials/tree.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ struct VsOut {
@location(1) world_normal: vec3<f32>,
// local-y of the vertex — used in fs to blend trunk vs leaf colour
@location(2) local_y: f32,
// EN-022 — clip positions for motion vectors.
@location(3) curr_clip: vec4<f32>,
@location(4) prev_clip: vec4<f32>,
};

@vertex
Expand Down Expand Up @@ -61,6 +64,19 @@ fn vs_main(in: VertexInput) -> VsOut {
out.world_normal = world_normal;
out.clip_pos = view.view_proj * vec4<f32>(world_pos, 1.0);
out.local_y = in.position.y;

// EN-022 — previous-frame position: the same sway evaluated one
// frame back (PerFrame carries delta_time), projected by the
// previous VP. Wind sway finally produces real motion vectors
// instead of ghosting through TAA with velocity 0.
let t_prev = frame.time - frame.delta_time;
let big_p = sin(t_prev * tp.wind.w + phase);
let small_p = sin(t_prev * tp.wind.w * 2.7 + phase * 1.6) * 0.35;
let sway_prev = (big_p + small_p) * tp.wind.z * w;
let prev_world = (draw.model * vec4<f32>(in.position, 1.0)).xyz
+ vec3<f32>(tp.wind.x, 0.0, tp.wind.y) * sway_prev;
out.curr_clip = out.clip_pos;
out.prev_clip = view.prev_view_proj * vec4<f32>(prev_world, 1.0);
return out;
}

Expand Down Expand Up @@ -91,27 +107,54 @@ fn fs_main(in: VsOut) -> OpaqueOut {
let albedo = mix(tp.trunk.rgb, draw.model_tint.rgb, trunk_t);

let sun_dir = normalize(view.sun_dir.xyz);
let v = normalize(view.camera_pos.xyz - in.world_pos);
let n_dot_l = max(dot(n, sun_dir), 0.0);
let cloud = cloud_shadow(in.world_pos.xz, frame.time);
// Cascaded sun shadow — trees shade themselves and each other now.
// Leaves take it at HALF strength: a low-poly canopy's flat facets
// Leaves take it at reduced strength: a low-poly canopy's flat facets
// terrace hard against their own blocky shadow-map silhouette (slat
// stripes), and real foliage scatters enough light that full-depth
// self-shadow reads wrong anyway. The trunk keeps the full factor,
// and the ground below still receives the full canopy shadow from
// its own material.
// self-shadow reads wrong anyway. Round-2 (audit F3): the floor
// deepens with distance — the flat 0.5 kept far crowns uniformly
// half-lit, one third of why the backlit treeline measured +35%
// brighter than the hills behind it. Facet terracing is sub-pixel
// past ~15 m, so the deep floor is safe there.
let sun_shadow_raw = sample_sun_shadow_n(in.world_pos, n);
let sun_shadow = mix(sun_shadow_raw, 0.5 + 0.5 * sun_shadow_raw, trunk_t);
let direct = view.sun_color.rgb * n_dot_l * cloud * sun_shadow;
// Sky-fill: HDR irradiance by the surface normal — canopy tops read
// sky-lit, undersides shade toward ground bounce — plus a small floor.
let fill = sample_env_diffuse(n) + view.ambient.rgb * 0.20;
let lit = albedo * (fill + direct);
let cam_dist = length(view.camera_pos.xyz - in.world_pos);
let leaf_floor = mix(0.5, 0.15, smoothstep(15.0, 45.0, cam_dist));
let sun_shadow = mix(sun_shadow_raw,
leaf_floor + (1.0 - leaf_floor) * sun_shadow_raw,
trunk_t);
// sun_dir.w carries the sun intensity (1.1 in the shipped world);
// dropping it tilted the fill:direct ratio toward the pale sky fill.
let direct = view.sun_color.rgb * view.sun_dir.w * n_dot_l * cloud * sun_shadow;
// Backlit transmission — RIM-gated. A first cut applied it to the
// whole camera-facing crown; on a backlit tree that is every visible
// pixel (-n·sun > 0 exactly where the camera looks), which brightened
// the very silhouettes the audit flagged. Real crowns glow at thin
// edges only, so gate by silhouette proximity.
let back = max(dot(-n, sun_dir), 0.0);
let view_align = max(dot(v, -sun_dir), 0.0);
let rim = pow(1.0 - abs(dot(n, v)), 2.0);
let trans = pow(back, 2.0) * pow(view_align, 1.5) * 0.6 * rim * trunk_t;
let trans_col = albedo * vec3<f32>(1.10, 1.20, 0.85);
// Sky-fill: HDR irradiance by the surface normal, with a crown
// self-occlusion proxy on the leaf band (audit F3): a solid canopy's
// sides see far less sky than its top, but env irradiance by normal
// alone gave every side facet ~half the sky dome — the core of why
// backlit crowns measured +35% over the hills behind them. Tops keep
// the full dome; sides drop to 35%.
let crown_ao = mix(1.0, 0.35 + 0.65 * max(n.y, 0.0), trunk_t);
let fill = (sample_env_diffuse(n) + view.ambient.rgb * 0.20) * crown_ao;
let lit = albedo * (fill + direct)
+ trans_col * trans * view.sun_color.rgb * view.sun_dir.w
* cloud * sun_shadow_raw;

var out: OpaqueOut;
out.hdr = vec4<f32>(lit, 1.0);
out.material = vec2<f32>(0.0, 0.85);
out.velocity = vec2<f32>(0.0, 0.0);
// EN-022 — real motion vectors, including the wind sway.
out.velocity = abi_motion_vector(in.curr_clip, in.prev_clip);
out.albedo = vec4<f32>(albedo, 1.0);
return out;
}
Loading