ui: move rendering logic into renderLoop singleton

This commit is contained in:
Johannes Kirschbauer
2025-07-28 20:20:42 +02:00
parent cb89fb97f1
commit dac06531d4
2 changed files with 160 additions and 72 deletions

View File

@@ -0,0 +1,131 @@
import { Scene, Camera, WebGLRenderer } from "three";
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
/**
* Private class dont try to import it
*
*/
class RenderLoop {
// Track if a render is already requested
// This prevents multiple requests in the same frame
// and ensures only one render per frame
// This is important for performance and to avoid flickering
private renderRequested = false;
// References to the scene, camera, renderer, controls, and label renderer
// These will be set during initialization
private scene!: Scene;
private bgScene!: Scene;
private camera!: Camera;
private bgCamera!: Camera;
private renderer!: WebGLRenderer;
private controls!: MapControls;
private labelRenderer!: CSS2DRenderer;
// Flag to prevent multiple initializations
private initialized = false;
init(
scene: Scene,
camera: Camera,
renderer: WebGLRenderer,
labelRenderer: CSS2DRenderer,
controls: MapControls,
bgScene: Scene,
bgCamera: Camera,
) {
if (this.initialized) {
console.error("RenderLoop already initialized.");
return;
}
this.scene = scene;
this.camera = camera;
this.renderer = renderer;
this.controls = controls;
this.bgScene = bgScene;
this.bgCamera = bgCamera;
this.labelRenderer = labelRenderer;
this.initialized = true;
}
requestRender() {
// If not initialized, log an error and return
if (!this.initialized) {
console.error(
"RenderLoop not initialized yet. Make sure to call init() once before usage.",
);
return;
}
// If a render is already requested, do nothing
if (this.renderRequested) return;
this.renderRequested = true;
requestAnimationFrame(() => {
this.updateTweens();
const needsUpdate = this.controls.update(); // returns true if damping is ongoing
this.render();
this.renderRequested = false;
if (needsUpdate) {
this.requestRender();
}
});
}
private updateTweens() {
// TODO: TWEEN.update() for tween animations in the future
}
private render() {
// TODO: Disable console.debug in production
console.debug("Rendering scene...", this);
this.renderer.autoClear = false;
this.renderer.clear();
this.renderer.render(this.bgScene, this.bgCamera);
this.controls.update();
this.renderer.render(this.scene, this.camera);
this.labelRenderer.render(this.scene, this.camera);
}
dispose() {
// Dispose controls, renderer, remove listeners if any
this.controls.dispose();
this.renderer.dispose();
// clear refs
this.initialized = false;
}
}
/**
* Singleton instance of RenderLoop
* This is used to manage the re-rendering
*
* It can only be initialized once then passed to individual components
* they can use the renderLoop to request re-renders as needed.
*
*
* Usage:
* ```typescript
* import { renderLoop } from "./RenderLoop";
*
* // Somewhere initialize the render loop:
* renderLoop.init(scene, camera, renderer, labelRenderer, controls, bgScene, bgCamera);
*
* // To request a render:
* renderLoop.requestRender();
*
* // To dispose:
* onCleanup(() => {
* renderLoop.dispose();
* })
*
*/
export const renderLoop = new RenderLoop();

View File

@@ -22,6 +22,7 @@ import { MachinesQueryResult } from "../queries/queries";
import { SceneData } from "../stores/clan";
import { unwrap } from "solid-js/store";
import { Accessor } from "solid-js";
import { renderLoop } from "./RenderLoop";
function garbageCollectGroup(group: THREE.Group) {
for (const child of group.children) {
@@ -88,8 +89,6 @@ export function CubeScene(props: {
const raycaster = new THREE.Raycaster();
let initBase: THREE.Mesh | undefined;
let needsRender = false; // Flag to control rendering
// Create background scene
const bgScene = new THREE.Scene();
const bgCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
@@ -101,13 +100,6 @@ export function CubeScene(props: {
let sharedCubeGeometry: THREE.BoxGeometry;
let sharedBaseGeometry: THREE.BoxGeometry;
// Used for development purposes
// Vite does hot-reload but we need to ensure the animation loop doesn't run multiple times
// This flag prevents multiple animation loops from running simultaneously
// It is set to true when the component mounts and false when it unmounts
let isAnimating = false; // Flag to prevent multiple loops
let frameCount = 0;
const [positionMode, setPositionMode] = createSignal<"grid" | "circle">(
"grid",
);
@@ -180,35 +172,6 @@ export function CubeScene(props: {
}
});
function requestRenderIfNotRequested() {
if (!needsRender) {
needsRender = true;
requestAnimationFrame(renderScene);
}
}
function renderScene() {
if (!isAnimating) {
console.warn("Not animating!");
return;
}
console.log("Rendering scene...", camera.toJSON());
needsRender = false;
frameCount++;
renderer.autoClear = false;
renderer.render(bgScene, bgCamera);
controls.update(); // optional; see note below
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
if (frameCount % 30 === 0) logMemoryUsage();
}
function getGridPosition(id: string): [number, number, number] {
// TODO: Detect collision with other cubes
const machine = props.sceneStore()[id];
@@ -341,7 +304,7 @@ export function CubeScene(props: {
if (progress < 1) {
requestAnimationFrame(animate);
requestRenderIfNotRequested();
renderLoop.requestRender();
}
}
@@ -431,20 +394,7 @@ export function CubeScene(props: {
cubeMaterial.color.set(CUBE_COLOR);
}
requestRenderIfNotRequested();
}
function logMemoryUsage() {
if (renderer && renderer.info) {
console.debug("Three.js Memory:", {
frame: renderer.info.render.frame,
calls: renderer.info.render.calls,
geometries: renderer.info.memory.geometries,
textures: renderer.info.memory.textures,
programs: renderer.info.programs?.length || 0,
triangles: renderer.info.render.triangles,
});
}
renderLoop.requestRender();
}
const initialCameraPosition = { x: 20, y: 20, z: 20 };
@@ -548,17 +498,26 @@ export function CubeScene(props: {
controls.minZoom = 1.2;
controls.maxZoom = 3.5;
controls.addEventListener("change", () => {
const aspect = container.clientWidth / container.clientHeight;
const zoom = camera.zoom;
camera.left = (-d * aspect) / zoom;
camera.right = (d * aspect) / zoom;
camera.top = d / zoom;
camera.bottom = -d / zoom;
camera.updateProjectionMatrix();
requestRenderIfNotRequested();
// const aspect = container.clientWidth / container.clientHeight;
// const zoom = camera.zoom;
// camera.left = (-d * aspect) / zoom;
// camera.right = (d * aspect) / zoom;
// camera.top = d / zoom;
// camera.bottom = -d / zoom;
// camera.updateProjectionMatrix();
renderLoop.requestRender();
});
renderLoop.init(
scene,
camera,
renderer,
labelRenderer,
controls,
bgScene,
bgCamera,
);
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
scene.add(ambientLight);
@@ -667,7 +626,7 @@ export function CubeScene(props: {
} else {
initBase!.visible = false;
}
requestRenderIfNotRequested();
renderLoop.requestRender();
}),
);
@@ -718,9 +677,7 @@ export function CubeScene(props: {
renderer.domElement.addEventListener("click", onClick);
isAnimating = true;
requestRenderIfNotRequested();
renderLoop.requestRender();
// Handle window resize
const handleResize = () => {
@@ -742,7 +699,7 @@ export function CubeScene(props: {
);
renderer.render(bgScene, bgCamera);
requestRenderIfNotRequested();
renderLoop.requestRender();
};
renderer.domElement.addEventListener("mousemove", onMouseMove);
@@ -760,8 +717,8 @@ export function CubeScene(props: {
// Cleanup function
onCleanup(() => {
// Stop animation loop
isAnimating = false;
renderLoop.dispose();
renderer.domElement.removeEventListener("click", onClick);
renderer.domElement.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("resize", handleResize);
@@ -886,7 +843,7 @@ export function CubeScene(props: {
}
});
requestRenderIfNotRequested();
renderLoop.requestRender();
});
createEffect(
@@ -919,7 +876,7 @@ export function CubeScene(props: {
initBase.position.set(pos[0], BASE_HEIGHT / 2, pos[1]);
initBase.visible = true;
}
requestRenderIfNotRequested();
renderLoop.requestRender();
};
const onAddClick = (event: MouseEvent) => {
@@ -955,7 +912,7 @@ export function CubeScene(props: {
// Only request render if the position actually changed
initBase.position.set(snapped.x, 0, snapped.z);
setCursorPosition([snapped.x, snapped.z]); // Update next position for cube creation
requestRenderIfNotRequested();
renderLoop.requestRender();
}
}
};