diff --git a/.gitignore b/.gitignore index 41f7ff1..dccad5e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ node_modules *.tsbuildinfo dist .opencode -builds \ No newline at end of file +builds +tmp +out \ No newline at end of file diff --git a/app/level.ts b/app/level.ts index 8e5b50b..9ca4a10 100644 --- a/app/level.ts +++ b/app/level.ts @@ -99,7 +99,7 @@ export function buildLevel(): Level { sun: Color.rgb(255, 246, 214), sunDir: { x: 0.3, y: 0.5, z: -0.8 }, sunSize: 0.04, - clouds: fancyCumulus, + clouds: basicCumulus, } return { floor, walls, crate, colliders, npcPosition: { x: 2, y: 0, z: -1 }, sky } diff --git a/app/main.ts b/app/main.ts index c72ba1d..2306876 100644 --- a/app/main.ts +++ b/app/main.ts @@ -67,6 +67,11 @@ async function main(): Promise { globalThis.addEventListener("keyup", (e) => { keys.delete(e.code) }) + // Losing focus (alt-tab, pointer-lock exit) drops keyup events, so clear held + // keys or movement sticks on. + globalThis.addEventListener("blur", () => { + keys.clear() + }) screen.addEventListener("click", () => { screen.requestPointerLock() }) @@ -108,7 +113,7 @@ async function main(): Promise { Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config) Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config) Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config) - Rasterizer.draw(fb, Sprite.billboard(npc, camera), textures.npc, viewProj, config) + Rasterizer.draw(fb, Sprite.billboard(npc, camera), npc.texture, viewProj, config) Framebuffer.quantize(fb, config) present() requestAnimationFrame(frame) diff --git a/engine/math/Vec3.ts b/engine/math/Vec3.ts index de003df..9ad02bf 100644 --- a/engine/math/Vec3.ts +++ b/engine/math/Vec3.ts @@ -33,6 +33,8 @@ export namespace Vec3 { export function normalize(v: Vec3): Vec3 { const len = length(v) - return len === 0 ? v : scale(v, 1 / len) + // Fresh zero (not the input alias) to keep the namespace-wide no-aliasing + // guarantee even on the degenerate case. + return len === 0 ? { x: 0, y: 0, z: 0 } : scale(v, 1 / len) } } diff --git a/engine/render/Color.ts b/engine/render/Color.ts index e6c37f0..5821e94 100644 --- a/engine/render/Color.ts +++ b/engine/render/Color.ts @@ -9,8 +9,13 @@ export type Color = number export namespace Color { export function rgb(r: number, g: number, b: number, a = 255): Color { // The shifts coerce the (possibly fractional) inputs to int32 and pack the - // channels; `>>> 0` forces an unsigned result so it stays a valid Color. - return ((a << 24) | (b << 16) | (g << 8) | r) >>> 0 + // channels. Left as a *signed* int32 on purpose: an unsigned `>>> 0` would + // push opaque colors (alpha 255) past V8's Smi range, so every one would be + // heap-boxed -- and the per-pixel sky/raster loops mint millions per frame, + // enough to trigger visible GC pauses. Signed keeps them small Smis. Every + // consumer extracts channels with `&`/`>>>` and stores through ToUint32, so + // the sign is invisible to them. + return (a << 24) | (b << 16) | (g << 8) | r } export function r(c: Color): number { @@ -34,12 +39,16 @@ export namespace Color { return rgb(r(c) * s, g(c) * s, b(c) * s, a(c)) } - /** Linear blend between two colors, t in 0..1. Used for fog and bilinear. */ + /** Linear blend between two colors, t in 0..1. Used for fog and bilinear. + * Alpha is interpolated too: bilinear texture sampling relies on it so the + * sprite alpha cutout still sees transparent edges (opaque colors blend to + * opaque, so fog/sky are unaffected). */ export function lerp(from: Color, to: Color, t: number): Color { return rgb( r(from) + (r(to) - r(from)) * t, g(from) + (g(to) - g(from)) * t, b(from) + (b(to) - b(from)) * t, + a(from) + (a(to) - a(from)) * t, ) } }