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 @@ -72,3 +72,8 @@ assets/textures/external/platanus/
# artefact, not source.
dxcompiler.dll
dxil.dll

# SH-054 web build: vendored JoltPhysics.js download cache (build-web.sh
# re-fetches it; the CDN fallback covers the offline case)
tools/web/

18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,26 @@ bun tools/build-terrain-textures.ts # rebuild the 4 terrain splat layers
bun tools/build-building-textures.ts # rebuild the building wall/slab slices
./tools/deploy-ios.sh # build + sign + install + launch on iPhone
./tools/deploy-ios.sh --console # ...and stream the device's stdout/stderr
./tools/build-web.sh # SH-054: browser build -> dist/web (add --serve to host it)
bun tools/web-check.ts # headless-Edge boot check (console + screenshot)
bun tools/web-play-check.ts # drives PLAY, walks, fires, screenshots gameplay
```

### Web (browser, WASM + WebGPU)

`tools/build-web.sh` produces `dist/web`: the engine's `bloom_web.wasm`
(wasm-pack, `../engine/native/web`), the Perry game wasm (`--target web`,
spliced HTML), the asset tree plus `assets_manifest.json` (the JS glue
prefetches it and treats it as authoritative for `fileExists`). Serve the
directory (`python -m http.server 8080`) — `file://` won't do, the loaders
fetch same-origin. WebGPU required (Chrome/Edge 113+); there is no WebGL2
fallback for the 3D path. The main loop is `runGame(gameFrame)` — identical
blocking loop on native, rAF on web — so never reintroduce a bare
`while (!windowShouldClose())`. Web-specific guards hang off `WEB` in
`src/input.ts`. Writes via `writeFile` land in localStorage under
`bloom_fs:`. Three Perry wasm-codegen fixes this port depends on live on
perry branch `fix/wasm-new-array-sized`; the engine side is EN-063.

### iOS

`tools/deploy-ios.sh` is the whole loop. Set `BLOOM_IOS_DEVICE` to target a
Expand Down
2 changes: 1 addition & 1 deletion assets/materials/building.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ struct BldgParams {
@group(2) @binding(11) var<uniform> bp: BldgParams;

struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@invariant @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.
Expand Down
2 changes: 1 addition & 1 deletion assets/materials/decal.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ struct InstancedVertexInput {
};

struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@invariant @builtin(position) clip_pos: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) tint: vec4<f32>,
@location(2) world_pos: vec3<f32>,
Expand Down
2 changes: 1 addition & 1 deletion assets/materials/glass.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
#include "common/pbr.wgsl"

struct GlassVsOut {
@builtin(position) clip_pos: vec4<f32>,
@invariant @builtin(position) clip_pos: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) world_normal: vec3<f32>,
@location(2) screen_uv: vec2<f32>,
Expand Down
2 changes: 1 addition & 1 deletion assets/materials/grass_instanced.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ struct InstancedVertexInput {
};

struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@invariant @builtin(position) clip_pos: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) world_normal: vec3<f32>,
@location(2) blade_tint: vec3<f32>,
Expand Down
2 changes: 1 addition & 1 deletion assets/materials/muzzle_flash.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
#include "material_abi.wgsl"

struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@invariant @builtin(position) clip_pos: vec4<f32>,
@location(0) local_pos: vec3<f32>,
};

Expand Down
2 changes: 1 addition & 1 deletion assets/materials/particle.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ struct InstancedVertexInput {
};

struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@invariant @builtin(position) clip_pos: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) tint: vec4<f32>,
@location(2) view_z: f32,
Expand Down
34 changes: 25 additions & 9 deletions assets/materials/terrain.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ struct TerrainParams {
@group(2) @binding(11) var<uniform> tp: TerrainParams;

struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@invariant @builtin(position) clip_pos: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) world_normal: vec3<f32>,
@location(2) curr_clip: vec4<f32>,
Expand Down Expand Up @@ -124,10 +124,21 @@ fn tri_albedo(layer: i32, p: vec3<f32>, w: vec3<f32>, s: f32) -> vec3<f32> {
/// Triplanar normal, whiteout-blended: sample each plane's tangent normal,
/// swizzle it into world space, and sum. Cheaper than a full TBN per plane and
/// visually indistinguishable on terrain.
fn tri_normal(layer: i32, p: vec3<f32>, w: vec3<f32>, s: f32, n: vec3<f32>) -> vec3<f32> {
let tx = textureSample(normal_array, albedo_array_samp, p.zy * s, layer).rgb * 2.0 - 1.0;
let ty = textureSample(normal_array, albedo_array_samp, p.xz * s, layer).rgb * 2.0 - 1.0;
let tz = textureSample(normal_array, albedo_array_samp, p.xy * s, layer).rgb * 2.0 - 1.0;
///
/// Sampled with EXPLICIT gradients (`dpx`/`dpy` = dpdx/dpdy of `p`, taken by
/// the caller in uniform control flow): this function is reached from the
/// per-fragment layer-select branch, where WGSL's uniformity analysis —
/// enforced by Tint on WebGPU, waved through by naga natively — forbids the
/// implicit-derivative `textureSample`. The scaled gradients reproduce the
/// exact mip math the implicit path used, so nothing changes visually.
fn tri_normal(layer: i32, p: vec3<f32>, w: vec3<f32>, s: f32, n: vec3<f32>,
dpx: vec3<f32>, dpy: vec3<f32>) -> vec3<f32> {
let tx = textureSampleGrad(normal_array, albedo_array_samp, p.zy * s, layer,
dpx.zy * s, dpy.zy * s).rgb * 2.0 - 1.0;
let ty = textureSampleGrad(normal_array, albedo_array_samp, p.xz * s, layer,
dpx.xz * s, dpy.xz * s).rgb * 2.0 - 1.0;
let tz = textureSampleGrad(normal_array, albedo_array_samp, p.xy * s, layer,
dpx.xy * s, dpy.xy * s).rgb * 2.0 - 1.0;
let nx = vec3<f32>(0.0, tx.y, tx.x) + n;
let ny = vec3<f32>(ty.x, 0.0, ty.y) + n;
let nz = vec3<f32>(tz.x, tz.y, 0.0) + n;
Expand Down Expand Up @@ -226,15 +237,20 @@ fn fs_main(in: VsOut) -> OpaqueOut {
// ---- normal -------------------------------------------------------------
// Blend the dominant layer's normal (blending four normal maps by weight
// washes them all out; picking by weight keeps the relief crisp).
// Position gradients for tri_normal's explicit-grad sampling — must be
// taken here, in uniform control flow, not inside the branch (see the
// tri_normal note).
let tn_dpx = dpdx(p);
let tn_dpy = dpdy(p);
var nrm = n;
if (w_rock > 0.5) {
nrm = tri_normal(3, p, w, s_rock, n);
nrm = tri_normal(3, p, w, s_rock, n, tn_dpx, tn_dpy);
} else if (w_dirt > 0.4) {
nrm = tri_normal(2, p, w, s_dirt, n);
nrm = tri_normal(2, p, w, s_dirt, n, tn_dpx, tn_dpy);
} else if (w_dry > 0.5) {
nrm = tri_normal(1, p, w, s_dry, n);
nrm = tri_normal(1, p, w, s_dry, n, tn_dpx, tn_dpy);
} else {
nrm = tri_normal(0, p, w, s_lush, n);
nrm = tri_normal(0, p, w, s_lush, n, tn_dpx, tn_dpy);
}
nrm = normalize(mix(n, nrm, tp.scales.w));

Expand Down
2 changes: 1 addition & 1 deletion assets/materials/water.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ struct WaterParams {
@group(2) @binding(11) var<uniform> water_params: WaterParams;

struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@invariant @builtin(position) clip_pos: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) world_normal: vec3<f32>,
};
Expand Down
66 changes: 66 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,18 @@
"perry": {
"allow": {
"nativeLibrary": [
"bloom", "bloom/core", "bloom/scene", "bloom/physics", "bloom/world",
"bloom/models", "bloom/audio", "bloom/vfx"
"bloom",
"bloom/core",
"bloom/scene",
"bloom/physics",
"bloom/world",
"bloom/models",
"bloom/audio",
"bloom/vfx"
]
}
},
"devDependencies": {
"puppeteer-core": "^25.3.0"
}
}
20 changes: 20 additions & 0 deletions src/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
isAnyInputPressed,
} from 'bloom';
import { writeFile } from 'bloom/core';
import { WEB } from './input';

// ---- Stage table ------------------------------------------------------------
// The bar is only honest if the weights are what the stages actually COST, so
Expand Down Expand Up @@ -231,6 +232,20 @@ function footer(sw: number, sh: number, s: number, a: number): void {
export function bootSplash(seconds: number): void {
B[2] = getTime();
B[3] = getTime();
// Web: this whole sequence runs synchronously inside the game module's
// top-level code, before the browser ever composites a frame — an animated
// loop here is invisible and just delays boot. One static frame instead
// (the JS shell owns the visible loading UI).
if (WEB) {
const sw = getScreenWidth();
const sh = getScreenHeight();
beginDrawing();
backdrop(sw, sh);
wordmark(sw, sh, sh / 720, 255);
footer(sw, sh, sh / 720, 255);
endDrawing();
return;
}
const t0 = getTime();
let t = 0;
while (t < seconds) {
Expand Down Expand Up @@ -288,6 +303,11 @@ export function bootOutro(): void {
timingLog = timingLog + 'TOTAL = ' + total.toFixed(0) + ' ms\n';
writeFile(TIMING_PATH, timingLog);
}
// Web: same reasoning as bootSplash — synchronous hold/fade loops are
// invisible in a browser (nothing composites until this returns) and only
// delay the first playable frame.
if (WEB) return;

const holdT = getTime();
while (getTime() - holdT < 0.35) {
paintLoading(1.0, 'ready');
Expand Down
8 changes: 7 additions & 1 deletion src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
isKeyDown, isKeyPressed,
isMouseButtonDown, isMouseButtonPressed,
getMouseDeltaX, getMouseDeltaY,
isMobile,
isMobile, getPlatform,
getTouchX, getTouchY, isTouchActive, getMaxTouchPoints,
getScreenWidth, getScreenHeight,
drawCircle, drawCircleLines, drawText, measureText,
Expand Down Expand Up @@ -78,6 +78,12 @@ const JOY_DEADZONE = 0.14;

export const MOBILE = isMobile();

// SH-054 — running in a browser (Perry --target web, engine on WebGPU).
// Deliberately distinct from MOBILE: desktop web is keyboard+mouse and full
// HUD; what changes on web is the frame loop (runGame → rAF), CLI/file
// access, and the render profile.
export const WEB = getPlatform() === 7;

// ---- Touch state ------------------------------------------------------------
// Which touch slot owns each control, or T_NONE. Tracking the slot — rather
// than re-deriving the control from finger positions every frame — is what
Expand Down
Loading