mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
pagedraw: free 3D model renders and the model overlay's GPU state
render3DModelToDataUrl builds a throwaway renderer for every 2D-canvas snapshot of a 3D model. It freed geometries and materials but not textures, and never released the context, so each snapshot held a WebGL context until its canvas was collected. It now frees the scene with disposeObject3D and forces the context loss. Model3DOverlay had the same texture leak, and a GLB that finished loading after the overlay closed or switched models was still added to the disposed scene. Late loads are now freed instead. The overlay renders into a React-owned canvas that a model change reuses, so the context loss is forced only once that canvas has left the DOM.
This commit is contained in:
@@ -3,6 +3,7 @@ import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import Konva from "konva";
|
||||
import { disposeObject3D } from "@storyteller/ui-viewer-3d";
|
||||
import { Node } from "../Node";
|
||||
import {
|
||||
Model3DParams,
|
||||
@@ -226,11 +227,17 @@ export const Model3DOverlay = React.forwardRef<Model3DOverlayHandle, Model3DOver
|
||||
front.position.set(0, 4, 10);
|
||||
scene.add(front);
|
||||
|
||||
// Load model
|
||||
// Load model. Set by cleanup: a model that finishes loading after the
|
||||
// overlay closed or switched models is freed instead of added.
|
||||
let cancelled = false;
|
||||
const loader = new GLTFLoader();
|
||||
loader.load(
|
||||
node.modelUrl!,
|
||||
(gltf) => {
|
||||
if (cancelled) {
|
||||
disposeObject3D(gltf.scene);
|
||||
return;
|
||||
}
|
||||
const model = gltf.scene;
|
||||
|
||||
// Auto-fit + apply user scale
|
||||
@@ -261,18 +268,15 @@ export const Model3DOverlay = React.forwardRef<Model3DOverlayHandle, Model3DOver
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
controls.dispose();
|
||||
scene.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.geometry?.dispose();
|
||||
const mats = Array.isArray(obj.material)
|
||||
? obj.material
|
||||
: [obj.material];
|
||||
mats.forEach((m) => m?.dispose());
|
||||
}
|
||||
});
|
||||
disposeObject3D(scene);
|
||||
renderer.dispose();
|
||||
// A model change re-runs this effect on the same canvas, which hands
|
||||
// the next renderer this same context, so only force the loss once
|
||||
// the canvas has left the DOM (the overlay closed).
|
||||
if (!canvas.isConnected) renderer.forceContextLoss();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [node.modelUrl]);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
DEFAULT_MODEL3D_PARAMS,
|
||||
render3DModelToDataUrl,
|
||||
} from "./render3DModel";
|
||||
|
||||
const { renderers, gltfOutcome } = vi.hoisted(() => ({
|
||||
renderers: [] as FakeRenderer[],
|
||||
gltfOutcome: { scene: undefined as unknown, error: undefined as unknown },
|
||||
}));
|
||||
|
||||
type FakeRenderer = {
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
forceContextLoss: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
// jsdom has no WebGL; only the teardown calls matter here.
|
||||
vi.mock("three", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("three")>();
|
||||
class WebGLRenderer {
|
||||
domElement = { toDataURL: () => "data:image/png;base64,AAAA" };
|
||||
dispose = vi.fn();
|
||||
forceContextLoss = vi.fn();
|
||||
constructor() {
|
||||
renderers.push(this);
|
||||
}
|
||||
setClearColor() {}
|
||||
setSize() {}
|
||||
render() {}
|
||||
}
|
||||
return { ...actual, WebGLRenderer };
|
||||
});
|
||||
|
||||
vi.mock("three/examples/jsm/loaders/GLTFLoader.js", () => ({
|
||||
GLTFLoader: class {
|
||||
load(
|
||||
_url: string,
|
||||
onLoad: (gltf: { scene: unknown }) => void,
|
||||
_onProgress: unknown,
|
||||
onError: (error: unknown) => void,
|
||||
) {
|
||||
if (gltfOutcome.error) onError(gltfOutcome.error);
|
||||
else onLoad({ scene: gltfOutcome.scene });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe("render3DModelToDataUrl", () => {
|
||||
beforeEach(() => {
|
||||
renderers.length = 0;
|
||||
gltfOutcome.scene = undefined;
|
||||
gltfOutcome.error = undefined;
|
||||
});
|
||||
|
||||
it("frees the model and the renderer's context after rendering", async () => {
|
||||
const geometry = new THREE.BoxGeometry();
|
||||
const onDispose = vi.fn();
|
||||
geometry.addEventListener("dispose", onDispose);
|
||||
gltfOutcome.scene = new THREE.Mesh(geometry);
|
||||
|
||||
const dataUrl = await render3DModelToDataUrl(
|
||||
"model.glb",
|
||||
DEFAULT_MODEL3D_PARAMS,
|
||||
);
|
||||
|
||||
expect(dataUrl).toMatch(/^data:image\/png/);
|
||||
expect(onDispose).toHaveBeenCalledTimes(1);
|
||||
expect(renderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(renderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("frees the renderer's context when the model fails to load", async () => {
|
||||
gltfOutcome.error = new Error("404");
|
||||
|
||||
await expect(
|
||||
render3DModelToDataUrl("missing.glb", DEFAULT_MODEL3D_PARAMS),
|
||||
).rejects.toThrow("404");
|
||||
|
||||
expect(renderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(renderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { disposeObject3D } from "@storyteller/ui-viewer-3d";
|
||||
|
||||
export interface Model3DParams {
|
||||
cameraPosition: { x: number; y: number; z: number };
|
||||
@@ -76,18 +77,16 @@ function normalizeAndPlaceModel(
|
||||
model.position.y = -scaledBox.min.y;
|
||||
}
|
||||
|
||||
// The renderer and its canvas are throwaway, so free the scene and then the
|
||||
// context itself; otherwise each render holds a WebGL context until the
|
||||
// canvas is garbage collected.
|
||||
function disposeScene(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
): void {
|
||||
scene.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.geometry?.dispose();
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
mats.forEach((m) => m?.dispose());
|
||||
}
|
||||
});
|
||||
disposeObject3D(scene);
|
||||
renderer.dispose();
|
||||
renderer.forceContextLoss();
|
||||
}
|
||||
|
||||
export async function render3DModelToDataUrl(
|
||||
|
||||
@@ -88,6 +88,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../modal/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../viewer-3d/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user