Fantasy-Map-Generator/src/modules/webgl-layer.ts

130 lines
3.9 KiB
TypeScript

import { Group, OrthographicCamera, Scene, WebGLRenderer } from "three";
import { byId } from "../utils";
export interface WebGLLayerConfig {
id: string;
setup: (group: Group) => void; // called once after WebGL2 confirmed; add meshes to group
render?: (group: Group) => void; // called each frame before renderer.render(); update uniforms/geometry
dispose: (group: Group) => void; // called on unregister(); dispose all GPU objects in group
}
interface RegisteredLayer {
config: WebGLLayerConfig;
group: Group;
}
export class WebGL2LayerClass {
private canvas = byId("webgl-canvas")!;
private renderer: WebGLRenderer | null = null;
private camera: OrthographicCamera | null = null;
private scene: Scene | null = null;
private layers: Map<string, RegisteredLayer> = new Map();
private pendingConfigs: WebGLLayerConfig[] = []; // queue for register() before init()
private rafId: number | null = null;
init(): boolean {
this.renderer = new WebGLRenderer({
canvas: this.canvas,
antialias: false,
alpha: true,
});
this.renderer.setPixelRatio(window.devicePixelRatio || 1);
this.canvas.style.width = `${graphWidth}px`;
this.canvas.style.height = `${graphHeight}px`;
this.renderer.setSize(graphWidth, graphHeight, false);
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();
config.setup(group);
this.scene.add(group);
this.layers.set(config.id, { config, group });
}
this.pendingConfigs = [];
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;
const y = -viewY / scale;
const w = graphWidth / scale;
const h = graphHeight / scale;
this.camera.left = x;
this.camera.right = x + w;
this.camera.top = y;
this.camera.bottom = y + h;
this.camera.updateProjectionMatrix();
}
private render() {
if (!this.renderer || !this.scene || !this.camera) return;
this.syncTransform();
for (const layer of this.layers.values()) {
if (layer.group.visible && layer.config.render)
layer.config.render(layer.group);
}
this.renderer.render(this.scene, this.camera);
}
}
declare global {
var WebGLLayer: WebGL2LayerClass;
}
window.WebGLLayer = new WebGL2LayerClass();