mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-03-22 07:07:24 +01: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.
|
||||
|
||||
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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
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.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 {
|
||||
|
|
|
|||
|
|
@ -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 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue