From e007514af3b8aac104f76995c13df06cc2f59d18 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 08:10:28 +0000 Subject: [PATCH] scene: terrain falls back to its sky alone on a 2D canvas where WebGL2 is missing createTerrainScene returned null without WebGL2, leaving plain dark ground. The door now mounts terrain/TerrainSky2D.ts on the same canvas: skyColour (the CPU twin of SKY_FRAG) painted once, the star layer on top when dark, same ?sky= ?sun= ?az= choice. chooseSky moves from Terrain.ts to terrainSky.ts so both scenes share one owner. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 5 + src/app/features/scene/index.ts | 14 +- src/app/features/scene/terrain/Terrain.ts | 27 +-- .../scene/terrain/TerrainSky2D.test.ts | 220 ++++++++++++++++++ .../features/scene/terrain/TerrainSky2D.ts | 83 +++++++ src/app/features/scene/terrain/terrainSky.ts | 18 ++ 6 files changed, 337 insertions(+), 30 deletions(-) create mode 100644 src/app/features/scene/terrain/TerrainSky2D.test.ts create mode 100644 src/app/features/scene/terrain/TerrainSky2D.ts diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 5bf2ef59..35075536 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -51,6 +51,11 @@ Page URL: `?sky=midnight|noonday` picks a bake; `?sun=&az= Scene | null; try { if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; - else if (config?.style === 'terrain') - createScene = (await import('./terrain/Terrain')).createTerrainScene; - else createScene = (await import('./ridge/Ridge')).createRidgeScene; + else if (config?.style === 'terrain') { + const { createTerrainScene } = await import('./terrain/Terrain'); + const scene = createTerrainScene({ ...options, tier }); + // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. + if (scene) return scene; + const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); + return createTerrainSkyScene({ ...options, tier }); + } else createScene = (await import('./ridge/Ridge')).createRidgeScene; } catch (e) { options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); return null; diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 90d90ca2..3db90d3c 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -1,20 +1,9 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from '../ridge/probe'; -import { skyColours } from '../ridge/skyColour'; import { albedoPalette } from './terrainAlbedo'; import { sunIndex } from '../../../../sky/scatter.js'; import { SKY_VIEW, skyViewWords } from './terrainScatter'; -import { - glowColour, - HAZE_K, - isSkyName, - skyAtSun, - SkyData, - skyData, - SkyName, - skyNameFor, - sunDirection, -} from './terrainSky'; +import { glowColour, HAZE_K, chooseSky, SkyName, sunDirection } from './terrainSky'; import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; import { FULLSCREEN_VERT, @@ -77,20 +66,6 @@ function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: strin return p; } -/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ -function chooseSky(opts: Pick): SkyData { - const q = new URLSearchParams(window.location.search); - const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); - const elevation = opts.sun?.elevation ?? number('sun'); - if (Number.isFinite(elevation)) { - const azimuth = opts.sun?.azimuth ?? number('az'); - return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); - } - if (opts.sky) return skyData(opts.sky); - const name = q.get('sky'); - return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); -} - /** * The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the * CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an diff --git a/src/app/features/scene/terrain/TerrainSky2D.test.ts b/src/app/features/scene/terrain/TerrainSky2D.test.ts new file mode 100644 index 00000000..c3eb1e3d --- /dev/null +++ b/src/app/features/scene/terrain/TerrainSky2D.test.ts @@ -0,0 +1,220 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTerrainSkyScene, SKY_WIDTH } from './TerrainSky2D'; +import { startBackgroundScene } from '../index'; + +let search: string; +let arcs: number; +let overlay: { remove: ReturnType }; +let windowListeners: Record void>; +beforeEach(() => { + search = ''; + arcs = 0; + windowListeners = {}; + vi.stubGlobal('document', { + createElement: () => { + overlay = { + remove: vi.fn(), + style: {}, + setAttribute() {}, + getContext: () => ({ + clearRect() {}, + beginPath() {}, + fill() {}, + arc: () => { + arcs += 1; + }, + }), + } as any; + return overlay; + }, + }); + vi.stubGlobal('window', { + devicePixelRatio: 1, + innerWidth: 400, + innerHeight: 800, + addEventListener: (n: string, f: () => void) => { + windowListeners[n] = f; + }, + removeEventListener: (n: string) => { + delete windowListeners[n]; + }, + get location() { + return { search }; + }, + setTimeout, + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** A canvas with a recording 2D context; `gl` is what 'webgl2' answers. */ +function canvasWith(opts: { twoD?: boolean; gl?: unknown } = {}) { + const painted: { data: Uint8ClampedArray; width: number; height: number }[] = []; + const ctx = { + createImageData: (width: number, height: number) => ({ + width, + height, + data: new Uint8ClampedArray(width * height * 4), + }), + putImageData: (image: any) => painted.push(image), + }; + const parentAppend = vi.fn(); + const canvas: any = { + width: 0, + height: 0, + clientWidth: 400, + clientHeight: 800, + parentElement: { appendChild: parentAppend }, + getContext: vi.fn((kind: string) => { + if (kind !== '2d') return opts.gl ?? null; + return opts.twoD === false ? null : ctx; + }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + return { canvas, painted, parentAppend }; +} + +const pixel = (img: { data: Uint8ClampedArray; width: number }, x: number, y: number) => + Array.from(img.data.slice((y * img.width + x) * 4, (y * img.width + x) * 4 + 3)); +const mean = (img: { data: Uint8ClampedArray }) => { + let s = 0; + for (let i = 0; i < img.data.length; i += 4) s += img.data[i] + img.data[i + 1] + img.data[i + 2]; + return s / (img.data.length / 4) / 3; +}; + +describe('createTerrainSkyScene', () => { + it("returns null at tier 'off' and touches nothing", () => { + const { canvas } = canvasWith(); + expect(createTerrainSkyScene({ canvas, tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('returns null when the canvas gives no 2D context (the CSS ground stays)', () => { + const { canvas } = canvasWith({ twoD: false }); + expect(createTerrainSkyScene({ canvas, tier: 'low' })).toBeNull(); + }); + + it('paints one opaque, graded sky: the horizon end of the image is not the zenith end', () => { + const { canvas, painted } = canvasWith(); + const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; + expect(painted).toHaveLength(1); + const img = painted[0]; + expect(img.width).toBe(SKY_WIDTH); + expect(canvas.width).toBe(SKY_WIDTH); + expect(canvas.height).toBe(img.height); + for (let i = 3; i < img.data.length; i += 4) expect(img.data[i]).toBe(255); + const x = Math.floor(SKY_WIDTH / 2); + expect(pixel(img, x, 0)).not.toEqual(pixel(img, x, img.height - 1)); + expect(mean(img)).toBeGreaterThan(20); + s.dispose(); + }); + + it('follows the baked sky: noon is brighter than midnight, and ?sun= / ?az= move it', () => { + const noon = canvasWith(); + createTerrainSkyScene({ canvas: noon.canvas, tier: 'low', sky: 'noonday' })!.dispose(); + const night = canvasWith(); + createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!.dispose(); + expect(mean(noon.painted[0])).toBeGreaterThan(mean(night.painted[0]) * 1.5); + + const low = canvasWith(); + createTerrainSkyScene({ canvas: low.canvas, tier: 'low', sun: { elevation: 30 } })!.dispose(); + const down = canvasWith(); + createTerrainSkyScene({ canvas: down.canvas, tier: 'low', sun: { elevation: -20 } })!.dispose(); + expect(mean(low.painted[0])).toBeGreaterThan(mean(down.painted[0])); + + search = '?sun=30&az=0'; + const q0 = canvasWith(); + createTerrainSkyScene({ canvas: q0.canvas, tier: 'low' })!.dispose(); + search = '?sun=30&az=180'; + const q1 = canvasWith(); + createTerrainSkyScene({ canvas: q1.canvas, tier: 'low' })!.dispose(); + expect(Array.from(q0.painted[0].data)).not.toEqual(Array.from(q1.painted[0].data)); + }); + + it('stars only on a dark sky', () => { + const night = canvasWith(); + const a = createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!; + expect(night.parentAppend).toHaveBeenCalledTimes(1); + expect(arcs).toBeGreaterThan(0); + a.dispose(); + expect(overlay.remove).toHaveBeenCalled(); + arcs = 0; + const day = canvasWith(); + createTerrainSkyScene({ canvas: day.canvas, tier: 'low', sky: 'noonday' })!.dispose(); + expect(day.parentAppend).not.toHaveBeenCalled(); + expect(arcs).toBe(0); + }); + + it('repaints on resize, and dispose stops listening', () => { + vi.useFakeTimers(); + vi.stubGlobal('window', { + ...(window as any), + setTimeout, + addEventListener: (window as any).addEventListener, + }); + const { canvas, painted } = canvasWith(); + const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; + expect(windowListeners.resize).toBeDefined(); + windowListeners.resize(); + vi.advanceTimersByTime(300); + expect(painted).toHaveLength(2); + s.dispose(); + expect(windowListeners.resize).toBeUndefined(); + vi.useRealTimers(); + }); +}); + +describe('startBackgroundScene, terrain style', () => { + it('without WebGL2 mounts the sky on the same canvas instead of returning null', async () => { + const { canvas, painted } = canvasWith({ gl: null }); + const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2', '2d']); + expect(painted).toHaveLength(1); + scene!.dispose(); + }); + + it('with WebGL2 the terrain runs and the 2D sky is never requested', async () => { + const gl: any = new Proxy( + {}, + { + get(_t, name: string) { + if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true; + if (name === 'checkFramebufferStatus') return () => 1; + if (name === 'getParameter') return () => 16; + if (/^[A-Z_0-9]+$/.test(name)) return 1; + return () => ({}); + }, + } + ); + Object.assign(document, { + addEventListener() {}, + removeEventListener() {}, + visibilityState: 'visible', + }); + vi.stubGlobal('requestAnimationFrame', () => 1); + vi.stubGlobal('cancelAnimationFrame', () => {}); + vi.stubGlobal('performance', { now: () => 0 }); + (window as any).matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + const { canvas, painted } = canvasWith({ gl }); + const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2']); + expect(painted).toHaveLength(0); + scene!.dispose(); + }); + + it('a canvas that took a GL context but gives no scene still ends as null: no 2D context to paint on', async () => { + const { canvas } = canvasWith({ gl: null, twoD: false }); + expect( + await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }) + ).toBeNull(); + }); +}); diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts new file mode 100644 index 00000000..e61c6fee --- /dev/null +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -0,0 +1,83 @@ +import type { Scene } from '../Scene'; +import type { TerrainOptions } from './Terrain'; +import { chooseSky, skyColour, Vec3 } from './terrainSky'; +import { createTerrainStars } from './terrainStars'; +import { CAMERA, view } from './terrainWorld'; + +/** The sky is smooth, so it is painted this many px wide and stretched by the canvas's CSS size. */ +export const SKY_WIDTH = 96; + +/** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */ +export type TerrainSkyOptions = Pick; + +/** + * Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the + * same baked tables and the same `?sun=` `?az=` `?sky=` choice) for the camera at its start, still: nothing moves, so + * a device that cannot run WebGL2 pays one paint and one resize repaint. Below the horizon there is no terrain, so + * the image continues with the horizon's colour. Null for "keep the CSS ground": tier off, or the canvas already + * holds a WebGL context and gives no 2D one. + */ +export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { + if ((opts.tier ?? 'full') === 'off') return null; + const { canvas } = opts; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + const sky = chooseSky(opts); + const stars = createTerrainStars(canvas, sky); + const tanHalf = Math.tan(CAMERA.fov / 2); + let disposed = false; + let resizeTimer = 0; + + const paint = () => { + const w = SKY_WIDTH; + const cw = canvas.clientWidth || window.innerWidth; + const ch = canvas.clientHeight || window.innerHeight; + const h = Math.max(1, Math.round((w * ch) / cw)); + canvas.width = w; + canvas.height = h; + const v = view(cw / ch, 0); + const image = ctx.createImageData(w, h); + for (let y = 0; y < h; y += 1) { + const ny = 1 - ((y + 0.5) / h) * 2; + for (let x = 0; x < w; x += 1) { + const nx = ((x + 0.5) / w) * 2 - 1; + const d = [0, 1, 2].map( + (i) => v.forward[i] + nx * tanHalf * (cw / ch) * v.right[i] + ny * tanHalf * v.up[i] + ); + const len = Math.hypot(d[0], d[1], d[2]); + const c: Vec3 = skyColour(sky, [d[0] / len, d[1] / len, d[2] / len]); + const at = (y * w + x) * 4; + for (let i = 0; i < 3; i += 1) { + image.data[at + i] = Math.round(255 * Math.min(1, Math.max(0, c[i])) ** (1 / 2.2)); + } + image.data[at + 3] = 255; + } + } + ctx.putImageData(image, 0, 0); + stars?.draw(0); + }; + + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + if (disposed) return; + stars?.resize(); + paint(); + }, 250); + }; + window.addEventListener('resize', onResize); + paint(); + + return { + login() { + // Nothing moves, so there is no pull-back to start. + }, + dispose() { + if (disposed) return; + disposed = true; + clearTimeout(resizeTimer); + window.removeEventListener('resize', onResize); + stars?.dispose(); + }, + }; +} diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index a7e38a39..b77ddbf2 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -2,6 +2,7 @@ // bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms. import { baked } from '../../../../sky/baked'; import { isNight } from '../ridge/stars'; +import { skyColours } from '../ridge/skyColour'; import type { SkyColours } from '../ridge/skyColour'; import { HORIZON_FLOOR, scatterRadiance, transmittanceAt } from './terrainScatter'; @@ -149,3 +150,20 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, return lit * t + haze[i] * (1 - t); }) as unknown as Vec3; } + +/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +export function chooseSky(opts: { + sky?: SkyName; + sun?: { elevation: number; azimuth?: number }; +}): SkyData { + const q = new URLSearchParams(window.location.search); + const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); + const elevation = opts.sun?.elevation ?? number('sun'); + if (Number.isFinite(elevation)) { + const azimuth = opts.sun?.azimuth ?? number('az'); + return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); + } + if (opts.sky) return skyData(opts.sky); + const name = q.get('sky'); + return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); +}