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 <noreply@anthropic.com>
This commit is contained in:
Nibu 2026-11-08 08:10:28 +00:00
commit e007514af3
6 changed files with 337 additions and 30 deletions

View file

@ -51,6 +51,11 @@ Page URL: `?sky=midnight|noonday` picks a bake; `?sun=<elevation deg>&az=<deg fr
(`skyAtSun`: baked transmittance for the sun's colour, ambient and horizon blended from the midnight bake to the noonday one as
the sun climbs from -8 to 25 degrees). `?sun=` wins over `?sky=`.
Without WebGL2 (`createTerrainScene` returns null) the door mounts `terrain/TerrainSky2D.ts` on the same canvas: the sky alone, painted once on a
96 px wide 2D canvas from `skyColour` (the CPU twin of `SKY_FRAG`, same `?sky=` `?sun=` `?az=` choice via `chooseSky`), the star layer on
top when the sky is dark, nothing moving, no sun disc. Below the horizon the image continues in the horizon's colour. A canvas that already
holds a GL context gives no 2D one, so a GL failure after context creation (compile, framebuffer) still ends in the CSS ground.
- Sky: `1 - exp(-SKY_EXPOSURE * radiance)` plus a night glow (the midnight horizon colour, `nightness` 1 below -4 degrees, 0 above 4), a
sun disc from the sun's colour, and a +-0.5/255 dither on the 8-bit output. The table is single scattering only (no ground bounce,
no multiple scattering), so the noon horizon is pale yellow-green and a sun just under the horizon colours the whole sky orange,

View file

@ -12,7 +12,8 @@ export type StartOptions = Omit<SceneOptions, 'tier' | 'patchSource' | 'onCompil
/**
* The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no
* canvas context is requested; otherwise the scene code loads on demand, so it stays out of the
* main chunk. `config.style` picks the scene: the ridges unless it says 'raymarch' or 'terrain'. Null means
* main chunk. `config.style` picks the scene: the ridges unless it says 'raymarch' or 'terrain' (which falls back to its
* sky alone, `terrain/TerrainSky2D.ts`, where WebGL2 is missing). Null means
* "keep the CSS ground", including when that chunk fails to load (offline, stale deploy): the
* failure goes to onCompileFail and nothing throws.
*/
@ -26,9 +27,14 @@ export async function startBackgroundScene(
let createScene: (o: SceneOptions) => 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;

View file

@ -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<TerrainOptions, 'sky' | 'sun'>): 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

View file

@ -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<typeof vi.fn> };
let windowListeners: Record<string, () => 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();
});
});

View file

@ -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<TerrainOptions, 'canvas' | 'tier' | 'sky' | 'sun'>;
/**
* 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();
},
};
}

View file

@ -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()));
}