mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-04-05 06:57:24 +02:00
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:
parent
dc6ff785ba
commit
70e3eea4d1
8 changed files with 251 additions and 312 deletions
9
.github/copilot-instructions.md
vendored
9
.github/copilot-instructions.md
vendored
|
|
@ -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.
|
`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`:\*\*
|
**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`.
|
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 -->
|
<!-- BMAD:END -->
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,32 @@ Use exactly `>= 20` — no magic numbers, no alternative thresholds.
|
||||||
- Template literals over concatenation
|
- Template literals over concatenation
|
||||||
- No unused variables or imports (error level)
|
- 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
|
## Development Workflow
|
||||||
|
|
|
||||||
|
|
@ -460,7 +460,7 @@ function handleZoom(isScaleChanged, isPositionChanged) {
|
||||||
fitScaleBar(scaleBar, svgWidth, svgHeight);
|
fitScaleBar(scaleBar, svgWidth, svgHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layerIsOn("toggleRelief")) rerenderReliefIcons();
|
WebGLLayer.rerender(); // rerender WebGL layers on zoom
|
||||||
|
|
||||||
// zoom image converter overlay
|
// zoom image converter overlay
|
||||||
if (customization === 1) {
|
if (customization === 1) {
|
||||||
|
|
|
||||||
168
src/modules/texture-atlas-layer.ts
Normal file
168
src/modules/texture-atlas-layer.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -34,8 +34,6 @@ export class WebGL2LayerClass {
|
||||||
this.scene = new Scene();
|
this.scene = new Scene();
|
||||||
this.camera = new OrthographicCamera(0, graphWidth, 0, graphHeight, -1, 1);
|
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)
|
// Process pre-init registrations (register() before init() is explicitly safe)
|
||||||
for (const config of this.pendingConfigs) {
|
for (const config of this.pendingConfigs) {
|
||||||
const group = new Group();
|
const group = new Group();
|
||||||
|
|
@ -48,56 +46,6 @@ export class WebGL2LayerClass {
|
||||||
return true;
|
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() {
|
private syncTransform() {
|
||||||
if (!this.camera) return;
|
if (!this.camera) return;
|
||||||
const x = -viewX / scale;
|
const x = -viewX / scale;
|
||||||
|
|
@ -121,6 +69,28 @@ export class WebGL2LayerClass {
|
||||||
}
|
}
|
||||||
this.renderer.render(this.scene, this.camera);
|
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 {
|
declare global {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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 { RELIEF_SYMBOLS } from "../config/relief-config";
|
||||||
import type { ReliefIcon } from "../modules/relief-generator";
|
import type { ReliefIcon } from "../modules/relief-generator";
|
||||||
import { generateRelief } from "../modules/relief-generator";
|
import { generateRelief } from "../modules/relief-generator";
|
||||||
|
import { TextureAtlasLayer } from "../modules/texture-atlas-layer";
|
||||||
import { byId } from "../utils";
|
import { byId } from "../utils";
|
||||||
|
|
||||||
const textureCache = new Map<string, Texture>(); // set name → Texture
|
const atlases = Object.fromEntries(
|
||||||
let terrainGroup: Group | null = null;
|
Object.entries(RELIEF_SYMBOLS).map(([set, ids]) => {
|
||||||
let lastBuiltIcons: ReliefIcon[] | null = null;
|
|
||||||
let lastBuiltSet: string | null = null;
|
|
||||||
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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 n = ids.length || 1;
|
||||||
const cols = Math.ceil(Math.sqrt(n));
|
const cols = Math.ceil(Math.sqrt(n));
|
||||||
const rows = Math.ceil(n / cols);
|
return [
|
||||||
|
set,
|
||||||
|
{ url: `images/relief/${set}.png`, cols, rows: Math.ceil(n / cols) },
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const positions = new Float32Array(entries.length * 4 * 3);
|
const terrainLayer = new TextureAtlasLayer("terrain", atlases);
|
||||||
const uvs = new Float32Array(entries.length * 4 * 2);
|
let lastDrawnIcons: ReliefIcon[] | null = null;
|
||||||
const indices = new Uint32Array(entries.length * 6);
|
|
||||||
|
|
||||||
let vi = 0,
|
function resolveQuads(icons: ReliefIcon[]) {
|
||||||
ii = 0;
|
return icons.map((r) => {
|
||||||
for (const { icon: r, tileIndex } of entries) {
|
const id = r.href.startsWith("#") ? r.href.slice(1) : r.href;
|
||||||
const col = tileIndex % cols;
|
for (const [set, ids] of Object.entries(RELIEF_SYMBOLS)) {
|
||||||
const row = Math.floor(tileIndex / cols);
|
const tileIndex = ids.indexOf(id);
|
||||||
const u0 = col / cols,
|
if (tileIndex !== -1)
|
||||||
u1 = (col + 1) / cols;
|
return { atlasId: set, x: r.x, y: r.y, s: r.s, tileIndex };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
throw new Error(`Relief: unknown symbol href "${r.href}"`);
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
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 {
|
function drawSvg(icons: ReliefIcon[], parentEl: HTMLElement): void {
|
||||||
|
|
@ -196,30 +54,21 @@ window.drawRelief = (
|
||||||
if (type === "svg") {
|
if (type === "svg") {
|
||||||
drawSvg(icons, parentEl);
|
drawSvg(icons, parentEl);
|
||||||
} else {
|
} else {
|
||||||
const set = parentEl.getAttribute("set") || "simple";
|
if (icons !== lastDrawnIcons) {
|
||||||
if (icons !== lastBuiltIcons || set !== lastBuiltSet) {
|
terrainLayer.draw(resolveQuads(icons));
|
||||||
buildReliefScene(icons);
|
lastDrawnIcons = icons;
|
||||||
lastBuiltIcons = icons;
|
|
||||||
lastBuiltSet = set;
|
|
||||||
}
|
}
|
||||||
WebGLLayer.requestRender();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.undrawRelief = () => {
|
window.undrawRelief = () => {
|
||||||
WebGLLayer.clearLayer("terrain");
|
terrainLayer.clear();
|
||||||
lastBuiltIcons = null;
|
lastDrawnIcons = null;
|
||||||
lastBuiltSet = null;
|
|
||||||
const terrainEl = byId("terrain");
|
const terrainEl = byId("terrain");
|
||||||
if (terrainEl) terrainEl.innerHTML = "";
|
if (terrainEl) terrainEl.innerHTML = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
window.rerenderReliefIcons = () => {
|
|
||||||
WebGLLayer.requestRender();
|
|
||||||
};
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var drawRelief: (type?: "svg" | "webGL", parentEl?: HTMLElement) => void;
|
var drawRelief: (type?: "svg" | "webGL", parentEl?: HTMLElement) => void;
|
||||||
var undrawRelief: () => void;
|
var undrawRelief: () => void;
|
||||||
var rerenderReliefIcons: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,4 @@ declare global {
|
||||||
var viewY: number;
|
var viewY: number;
|
||||||
var changeFont: () => void;
|
var changeFont: () => void;
|
||||||
var getFriendlyHeight: (coords: [number, number]) => string;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue