feat: Introduce TextureAtlasLayer for efficient texture management and rendering

refactor: Streamline WebGL2LayerClass methods and remove unused code
refactor: Consolidate relief icon rendering logic and remove benchmarks
This commit is contained in:
Azgaar 2026-03-12 20:17:17 +01:00
parent dc6ff785ba
commit 70e3eea4d1
8 changed files with 251 additions and 312 deletions

View file

@ -63,7 +63,7 @@ Type `/bmad-` in Copilot Chat to see all available BMAD workflows and agent acti
`public/main.js` and all `public/modules/**/*.js` files are **plain `<script defer>` tags — NOT ES modules**. Every top-level declaration is a `window` property automatically.
Key globals always available on `window` at runtime: `scale`, `viewX`, `viewY`, `graphWidth`, `graphHeight`, `svgWidth`, `svgHeight`, `pack`, `grid`, `viewbox`, `svg`, `zoom`, `seed`, `options`, `byId`, `rn`, `tip`, `layerIsOn`, `drawRelief`, `undrawRelief`, `rerenderReliefIcons`, and many more.
Key globals always available on `window` at runtime: `scale`, `viewX`, `viewY`, `graphWidth`, `graphHeight`, `svgWidth`, `svgHeight`, `pack`, `grid`, `viewbox`, `svg`, `zoom`, `seed`, `options`, `byId`, `rn`, `tip`, `layerIsOn` and many more.
**Rule: In `src/**/\*.ts`(ES modules), just use the globals directly — they are declared as ambient globals in`src/types/global.ts`:\*\*
@ -78,4 +78,11 @@ viewbox.on("zoom.webgl", handler);
Full reference: see `docs/architecture-globals.md`.
### Code Style — Non-Negotiable Rules for All Agents
- **No unnecessary comments.** Code is self-documenting. Add a comment only when the _why_ is non-obvious from reading the code itself. Never describe what the code does.
- **Clean abstractions that don't leak.** Each abstraction fully owns its concern. Callers must not need to know implementation details. If a caller must pass a flag to alter internal behavior, the abstraction is wrong — split it.
- **No academic over-engineering.** No design patterns, extra layers, or wrapper objects unless the problem concretely requires them. Solve the current requirement; do not design for hypothetical future variants.
- **Minimal artifacts.** Only create files mandated by acceptance criteria. No speculative utils stubs, barrel re-exports, or helper files without multiple concrete callers.
<!-- BMAD:END -->

View file

@ -190,6 +190,32 @@ Use exactly `>= 20` — no magic numbers, no alternative thresholds.
- Template literals over concatenation
- No unused variables or imports (error level)
### Code Style Principles (enforced on all generated code)
**Concise — no noise:**
- No comments unless the _why_ is genuinely non-obvious from reading the code. Never narrate what the code does.
- No JSDoc/TSDoc on internal functions. Exported public API only if callers are outside `src/`.
- Good names eliminate comments. If you feel a comment is needed, improve the name first.
**Clean abstractions — no leaks:**
- Each abstraction fully owns its concern. Callers must not need internal implementation details to use it correctly.
- No thin wrapper classes that just re-expose another class's internals.
- Keep call depth shallow: if tracing a feature requires more than two levels of indirection, refactor toward directness.
**No academic over-engineering:**
- No design patterns (Factory, Strategy, Observer, etc.) unless the problem concretely requires the flexibility they provide.
- No wrapper objects or extra indirection layers that add zero behavior.
- Functions take ≤ 3 positional parameters. Use a plain options object only when fields are genuinely optional and vary by caller.
**Minimal artifacts:**
- Only create files directly required by the story's acceptance criteria.
- No per-domain helper/utils files unless there are ≥ 3 reusable functions with multiple callers.
- No barrel re-export files unless there is an actual cross-module consumer today.
---
## Development Workflow

View file

@ -460,7 +460,7 @@ function handleZoom(isScaleChanged, isPositionChanged) {
fitScaleBar(scaleBar, svgWidth, svgHeight);
}
if (layerIsOn("toggleRelief")) rerenderReliefIcons();
WebGLLayer.rerender(); // rerender WebGL layers on zoom
// zoom image converter overlay
if (customization === 1) {

View file

@ -0,0 +1,168 @@
import {
BufferAttribute,
BufferGeometry,
DoubleSide,
type Group,
LinearFilter,
LinearMipmapLinearFilter,
Mesh,
MeshBasicMaterial,
SRGBColorSpace,
type Texture,
TextureLoader,
} from "three";
export interface AtlasConfig {
url: string;
cols: number;
rows: number;
}
export interface AtlasQuad {
atlasId: string;
x: number;
y: number;
s: number;
tileIndex: number;
}
export class TextureAtlasLayer {
private group: Group | null = null;
private readonly textureCache = new Map<string, Texture>();
private readonly atlases: Record<string, AtlasConfig>;
constructor(id: string, atlases: Record<string, AtlasConfig>) {
this.atlases = atlases;
for (const [atlasId, config] of Object.entries(atlases)) {
this.preloadTexture(atlasId, config.url);
}
WebGLLayer.register({
id,
setup: (group) => {
this.group = group;
},
dispose: () => {
this.disposeAll();
},
});
}
draw(quads: AtlasQuad[]) {
if (!this.group) return;
this.disposeGroup();
const byAtlas = new Map<string, AtlasQuad[]>();
for (const q of quads) {
let arr = byAtlas.get(q.atlasId);
if (!arr) {
arr = [];
byAtlas.set(q.atlasId, arr);
}
arr.push(q);
}
for (const [atlasId, atlasQuads] of byAtlas) {
const texture = this.textureCache.get(atlasId);
const config = this.atlases[atlasId];
if (!texture || !config) continue;
this.group.add(buildMesh(atlasQuads, config, texture));
}
WebGLLayer.rerender();
}
clear() {
this.disposeGroup();
WebGLLayer.rerender();
}
private preloadTexture(atlasId: string, url: string) {
new TextureLoader().load(
url,
(texture) => {
texture.flipY = false;
texture.colorSpace = SRGBColorSpace;
texture.needsUpdate = true;
texture.minFilter = LinearMipmapLinearFilter;
texture.magFilter = LinearFilter;
texture.generateMipmaps = true;
this.textureCache.set(atlasId, texture);
},
undefined,
() => {
ERROR && console.error(`TextureAtlasLayer: failed to load "${url}"`);
},
);
}
private disposeGroup() {
if (!this.group) return;
this.group.traverse((obj) => {
if (obj instanceof Mesh) {
obj.geometry.dispose();
(obj.material as MeshBasicMaterial).dispose();
}
});
this.group.clear();
}
private disposeAll() {
this.disposeGroup();
for (const tex of this.textureCache.values()) tex.dispose();
this.textureCache.clear();
}
}
function buildMesh(
quads: AtlasQuad[],
atlas: AtlasConfig,
texture: Texture,
): Mesh {
const { cols, rows } = atlas;
const positions = new Float32Array(quads.length * 4 * 3);
const uvs = new Float32Array(quads.length * 4 * 2);
const indices = new Uint32Array(quads.length * 6);
let vi = 0,
ii = 0;
for (const q of quads) {
const col = q.tileIndex % cols;
const row = Math.floor(q.tileIndex / cols);
const u0 = col / cols,
u1 = (col + 1) / cols;
const v0 = row / rows,
v1 = (row + 1) / rows;
const x1 = q.x + q.s,
y1 = q.y + q.s;
const base = vi;
positions.set([q.x, q.y, 0], vi * 3);
uvs.set([u0, v0], vi * 2);
vi++;
positions.set([x1, q.y, 0], vi * 3);
uvs.set([u1, v0], vi * 2);
vi++;
positions.set([q.x, y1, 0], vi * 3);
uvs.set([u0, v1], vi * 2);
vi++;
positions.set([x1, y1, 0], vi * 3);
uvs.set([u1, v1], vi * 2);
vi++;
indices.set([base, base + 1, base + 3, base, base + 3, base + 2], ii);
ii += 6;
}
const geo = new BufferGeometry();
geo.setAttribute("position", new BufferAttribute(positions, 3));
geo.setAttribute("uv", new BufferAttribute(uvs, 2));
geo.setIndex(new BufferAttribute(indices, 1));
return new Mesh(
geo,
new MeshBasicMaterial({
map: texture,
transparent: true,
side: DoubleSide,
depthTest: false,
depthWrite: false,
}),
);
}

View file

@ -34,8 +34,6 @@ export class WebGL2LayerClass {
this.scene = new Scene();
this.camera = new OrthographicCamera(0, graphWidth, 0, graphHeight, -1, 1);
svg.on("zoom.webgl", () => this.requestRender());
// Process pre-init registrations (register() before init() is explicitly safe)
for (const config of this.pendingConfigs) {
const group = new Group();
@ -48,56 +46,6 @@ export class WebGL2LayerClass {
return true;
}
register(config: WebGLLayerConfig) {
if (!this.scene) {
// init() has not been called yet — queue for processing in init()
this.pendingConfigs.push(config);
return;
}
// Post-init registration: create group immediately
const group = new Group();
// group.renderOrder = config.renderOrder;
config.setup(group);
this.scene.add(group);
this.layers.set(config.id, { config, group });
}
unregister(id: string) {
const layer = this.layers.get(id);
if (!layer || !this.scene) return;
const scene = this.scene;
layer.config.dispose(layer.group);
scene.remove(layer.group);
this.layers.delete(id);
const anyVisible = [...this.layers.values()].some((l) => l.group.visible);
if (this.canvas && !anyVisible) this.canvas.style.display = "none";
}
setVisible(id: string, visible: boolean) {
const layer = this.layers.get(id);
if (!layer) return;
layer.group.visible = visible;
const anyVisible = [...this.layers.values()].some((l) => l.group.visible);
if (this.canvas) this.canvas.style.display = anyVisible ? "block" : "none";
if (visible) this.requestRender();
}
clearLayer(id: string) {
const layer = this.layers.get(id);
if (!layer) return;
layer.group.clear();
this.requestRender();
}
requestRender() {
if (this.rafId !== null) return;
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.render();
});
}
private syncTransform() {
if (!this.camera) return;
const x = -viewX / scale;
@ -121,6 +69,28 @@ export class WebGL2LayerClass {
}
this.renderer.render(this.scene, this.camera);
}
register(config: WebGLLayerConfig) {
if (!this.scene) {
// init() has not been called yet — queue for processing in init()
this.pendingConfigs.push(config);
return;
}
// Post-init registration: create group immediately
const group = new Group();
config.setup(group);
this.scene.add(group);
this.layers.set(config.id, { config, group });
}
rerender() {
if (this.rafId !== null) return;
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.render();
});
}
}
declare global {

View file

@ -1,76 +0,0 @@
import { BufferAttribute, BufferGeometry } from "three";
import { bench, describe } from "vitest";
import { RELIEF_SYMBOLS } from "../config/relief-config";
import type { ReliefIcon } from "../modules/relief-generator";
// Standalone geometry harness — mirrors production buildSetMesh() without modifying source.
// BufferGeometry and BufferAttribute are pure-JS objects; no GPU/WebGL context required.
function buildSetMeshBench(
entries: Array<{ icon: ReliefIcon; tileIndex: number }>,
set: string,
): BufferGeometry {
const ids = RELIEF_SYMBOLS[set] ?? [];
const n = ids.length || 1;
const cols = Math.ceil(Math.sqrt(n));
const rows = Math.ceil(n / cols);
const positions = new Float32Array(entries.length * 4 * 3);
const uvs = new Float32Array(entries.length * 4 * 2);
const indices = new Uint32Array(entries.length * 6);
let vi = 0;
let ii = 0;
for (const { icon: r, tileIndex } of entries) {
const col = tileIndex % cols;
const row = Math.floor(tileIndex / cols);
const u0 = col / cols;
const u1 = (col + 1) / cols;
const v0 = row / rows;
const v1 = (row + 1) / rows;
const x0 = r.x;
const x1 = r.x + r.s;
const y0 = r.y;
const y1 = r.y + r.s;
const base = vi;
positions.set([x0, y0, 0], vi * 3);
uvs.set([u0, v0], vi * 2);
vi++;
positions.set([x1, y0, 0], vi * 3);
uvs.set([u1, v0], vi * 2);
vi++;
positions.set([x0, y1, 0], vi * 3);
uvs.set([u0, v1], vi * 2);
vi++;
positions.set([x1, y1, 0], vi * 3);
uvs.set([u1, v1], vi * 2);
vi++;
indices.set([base, base + 1, base + 3, base, base + 3, base + 2], ii);
ii += 6;
}
const geo = new BufferGeometry();
geo.setAttribute("position", new BufferAttribute(positions, 3));
geo.setAttribute("uv", new BufferAttribute(uvs, 2));
geo.setIndex(new BufferAttribute(indices, 1));
return geo;
}
function makeIcons(n: number): Array<{ icon: ReliefIcon; tileIndex: number }> {
return Array.from({ length: n }, (_, i) => ({
icon: {
i,
href: "#relief-mount-1",
x: (i % 100) * 10,
y: Math.floor(i / 100) * 10,
s: 8,
},
tileIndex: i % 9,
}));
}
describe("draw-relief-icons geometry build benchmarks", () => {
bench("buildSetMesh — 1,000 icons (NFR-P1 proxy)", () => {
buildSetMeshBench(makeIcons(1000), "simple");
});
bench("buildSetMesh — 10,000 icons (NFR-P2 proxy)", () => {
buildSetMeshBench(makeIcons(10000), "simple");
});
});

View file

@ -1,175 +1,33 @@
import {
BufferAttribute,
BufferGeometry,
DoubleSide,
type Group,
LinearFilter,
LinearMipmapLinearFilter,
Mesh,
MeshBasicMaterial,
SRGBColorSpace,
type Texture,
TextureLoader,
} from "three";
import { RELIEF_SYMBOLS } from "../config/relief-config";
import type { ReliefIcon } from "../modules/relief-generator";
import { generateRelief } from "../modules/relief-generator";
import { TextureAtlasLayer } from "../modules/texture-atlas-layer";
import { byId } from "../utils";
const textureCache = new Map<string, Texture>(); // set name → Texture
let terrainGroup: Group | null = null;
let lastBuiltIcons: ReliefIcon[] | null = null;
let lastBuiltSet: string | null = null;
const atlases = Object.fromEntries(
Object.entries(RELIEF_SYMBOLS).map(([set, ids]) => {
const n = ids.length || 1;
const cols = Math.ceil(Math.sqrt(n));
return [
set,
{ url: `images/relief/${set}.png`, cols, rows: Math.ceil(n / cols) },
];
}),
);
WebGLLayer.register({
id: "terrain",
setup(group: Group): void {
terrainGroup = group;
for (const set of Object.keys(RELIEF_SYMBOLS)) loadTexture(set);
},
dispose(group: Group): void {
group.traverse((obj) => {
if (obj instanceof Mesh) {
obj.geometry.dispose();
(obj.material as MeshBasicMaterial).map?.dispose();
(obj.material as MeshBasicMaterial).dispose();
}
});
for (const tex of textureCache.values()) tex?.dispose();
textureCache.clear();
},
});
const terrainLayer = new TextureAtlasLayer("terrain", atlases);
let lastDrawnIcons: ReliefIcon[] | null = null;
function loadTexture(set: string): Promise<Texture | null> {
if (textureCache.has(set))
return Promise.resolve(textureCache.get(set) ?? null);
return new Promise((resolve) => {
const loader = new TextureLoader();
loader.load(
`images/relief/${set}.png`,
(texture) => {
texture.flipY = false;
texture.colorSpace = SRGBColorSpace;
texture.needsUpdate = true;
texture.minFilter = LinearMipmapLinearFilter;
texture.magFilter = LinearFilter;
texture.generateMipmaps = true;
textureCache.set(set, texture);
resolve(texture);
},
undefined,
() => {
ERROR && console.error(`Relief: failed to load atlas for "${set}"`);
resolve(null);
},
);
});
}
// map a symbol href to its atlas set and tile index
function resolveSprite(symbolHref: string): {
set: string;
tileIndex: number;
} {
const id = symbolHref.startsWith("#") ? symbolHref.slice(1) : symbolHref;
for (const [set, ids] of Object.entries(RELIEF_SYMBOLS)) {
const idx = ids.indexOf(id);
if (idx !== -1) return { set, tileIndex: idx };
}
throw new Error(`Relief: unknown symbol href "${symbolHref}"`);
}
// Build a Mesh with all icon quads for one atlas set.
function buildSetMesh(
entries: Array<{ icon: ReliefIcon; tileIndex: number }>,
set: string,
texture: Texture,
): Mesh {
const ids = RELIEF_SYMBOLS[set] ?? [];
const n = ids.length || 1;
const cols = Math.ceil(Math.sqrt(n));
const rows = Math.ceil(n / cols);
const positions = new Float32Array(entries.length * 4 * 3);
const uvs = new Float32Array(entries.length * 4 * 2);
const indices = new Uint32Array(entries.length * 6);
let vi = 0,
ii = 0;
for (const { icon: r, tileIndex } of entries) {
const col = tileIndex % cols;
const row = Math.floor(tileIndex / cols);
const u0 = col / cols,
u1 = (col + 1) / cols;
const v0 = row / rows,
v1 = (row + 1) / rows;
const x0 = r.x,
x1 = r.x + r.s;
const y0 = r.y,
y1 = r.y + r.s;
const base = vi;
positions.set([x0, y0, 0], vi * 3);
uvs.set([u0, v0], vi * 2);
vi++;
positions.set([x1, y0, 0], vi * 3);
uvs.set([u1, v0], vi * 2);
vi++;
positions.set([x0, y1, 0], vi * 3);
uvs.set([u0, v1], vi * 2);
vi++;
positions.set([x1, y1, 0], vi * 3);
uvs.set([u1, v1], vi * 2);
vi++;
indices.set([base, base + 1, base + 3, base, base + 3, base + 2], ii);
ii += 6;
}
const geo = new BufferGeometry();
geo.setAttribute("position", new BufferAttribute(positions, 3));
geo.setAttribute("uv", new BufferAttribute(uvs, 2));
geo.setIndex(new BufferAttribute(indices, 1));
const mat = new MeshBasicMaterial({
map: texture,
transparent: true,
side: DoubleSide,
depthTest: false,
depthWrite: false,
});
return new Mesh(geo, mat);
}
function buildReliefScene(icons: ReliefIcon[]): void {
if (!terrainGroup) return;
terrainGroup.traverse((obj) => {
if (obj instanceof Mesh) {
obj.geometry.dispose();
(obj.material as MeshBasicMaterial).dispose();
function resolveQuads(icons: ReliefIcon[]) {
return icons.map((r) => {
const id = r.href.startsWith("#") ? r.href.slice(1) : r.href;
for (const [set, ids] of Object.entries(RELIEF_SYMBOLS)) {
const tileIndex = ids.indexOf(id);
if (tileIndex !== -1)
return { atlasId: set, x: r.x, y: r.y, s: r.s, tileIndex };
}
throw new Error(`Relief: unknown symbol href "${r.href}"`);
});
terrainGroup.clear();
const bySet = new Map<
string,
Array<{ icon: ReliefIcon; tileIndex: number }>
>();
for (const r of icons) {
const { set, tileIndex } = resolveSprite(r.href);
let arr = bySet.get(set);
if (!arr) {
arr = [];
bySet.set(set, arr);
}
arr.push({ icon: r, tileIndex });
}
for (const [set, setEntries] of bySet) {
const texture = textureCache.get(set);
if (!texture) continue;
terrainGroup.add(buildSetMesh(setEntries, set, texture));
}
}
function drawSvg(icons: ReliefIcon[], parentEl: HTMLElement): void {
@ -196,30 +54,21 @@ window.drawRelief = (
if (type === "svg") {
drawSvg(icons, parentEl);
} else {
const set = parentEl.getAttribute("set") || "simple";
if (icons !== lastBuiltIcons || set !== lastBuiltSet) {
buildReliefScene(icons);
lastBuiltIcons = icons;
lastBuiltSet = set;
if (icons !== lastDrawnIcons) {
terrainLayer.draw(resolveQuads(icons));
lastDrawnIcons = icons;
}
WebGLLayer.requestRender();
}
};
window.undrawRelief = () => {
WebGLLayer.clearLayer("terrain");
lastBuiltIcons = null;
lastBuiltSet = null;
terrainLayer.clear();
lastDrawnIcons = null;
const terrainEl = byId("terrain");
if (terrainEl) terrainEl.innerHTML = "";
};
window.rerenderReliefIcons = () => {
WebGLLayer.requestRender();
};
declare global {
var drawRelief: (type?: "svg" | "webGL", parentEl?: HTMLElement) => void;
var undrawRelief: () => void;
var rerenderReliefIcons: () => void;
}

View file

@ -91,9 +91,4 @@ declare global {
var viewY: number;
var changeFont: () => void;
var getFriendlyHeight: (coords: [number, number]) => string;
var WebGLLayer: import("../modules/webgl-layer").WebGL2LayerClass;
var drawRelief: (type?: "svg" | "webGL", parentEl?: HTMLElement) => void;
var undrawRelief: () => void;
var rerenderReliefIcons: () => void;
}