concept-collection / turing-sphere
266 lines · 9.0 KBBlameHistoryRaw
1import * as THREE from 'three';
2import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
4/**
5 * Three.js scene wrapper: a single indexed triangle mesh with static
6 * per-vertex positions and dynamic per-vertex colors, orbit controls, and
7 * optional camera synchronization with sibling scenes.
8 *
9 * Rendering is on demand: the animation loop ticks every frame (it has to,
10 * to drive OrbitControls damping), but only re-renders when the colors,
11 * camera, or canvas size actually changed.
12 *
13 * Adapted from figpack's SphereEmbedding view (figpack_experimental).
14 */
15export class SphereScene {
16 #scene: THREE.Scene;
17 #camera: THREE.PerspectiveCamera;
18 #renderer: THREE.WebGLRenderer;
19 #controls: OrbitControls;
20 #geometry: THREE.BufferGeometry;
21 #mesh: THREE.Mesh;
22 #animationId: number | null = null;
23 #defaultCameraState: {
24 position: THREE.Vector3;
25 target: THREE.Vector3;
26 } | null = null;
27 #syncing = false;
28 #needsRender = true;
29 #lastW = -1;
30 #lastH = -1;
32 constructor(
33 container: HTMLElement,
34 numVertices: number,
35 indices: Uint32Array,
36 positions: Float32Array,
37 background = '#14161c',
38 ) {
39 this.#scene = new THREE.Scene();
40 this.#scene.background = new THREE.Color(background);
42 this.#camera = new THREE.PerspectiveCamera(50, 1, 0.01, 1000);
44 this.#renderer = new THREE.WebGLRenderer({ antialias: true });
45 this.#renderer.setPixelRatio(window.devicePixelRatio || 1);
46 // The canvas always fills its container via CSS; resize() then only
47 // updates the drawing buffer
48 this.#renderer.domElement.style.width = '100%';
49 this.#renderer.domElement.style.height = '100%';
50 this.#renderer.domElement.style.display = 'block';
51 container.appendChild(this.#renderer.domElement);
53 // Lighting: ambient plus a headlight attached to the camera so the
54 // surface stays lit from the viewing direction as it is rotated
55 this.#scene.add(new THREE.AmbientLight(0xffffff, 0.65));
56 const headlight = new THREE.DirectionalLight(0xffffff, 1.6);
57 headlight.position.set(0.5, 0.8, 1);
58 this.#camera.add(headlight);
59 this.#scene.add(this.#camera);
61 this.#geometry = new THREE.BufferGeometry();
62 const positionAttr = new THREE.BufferAttribute(positions, 3);
63 const colorAttr = new THREE.BufferAttribute(
64 new Float32Array(numVertices * 3),
65 3,
66 );
67 colorAttr.setUsage(THREE.DynamicDrawUsage);
68 this.#geometry.setAttribute('position', positionAttr);
69 this.#geometry.setAttribute('color', colorAttr);
70 this.#geometry.setIndex(new THREE.BufferAttribute(indices, 1));
71 this.#geometry.computeVertexNormals();
72 this.#geometry.computeBoundingSphere();
74 const material = new THREE.MeshPhongMaterial({
75 vertexColors: true,
76 side: THREE.DoubleSide,
77 shininess: 25,
78 specular: new THREE.Color(0x222222),
79 });
80 this.#mesh = new THREE.Mesh(this.#geometry, material);
81 this.#scene.add(this.#mesh);
83 this.#controls = new OrbitControls(this.#camera, this.#renderer.domElement);
84 this.#controls.enableDamping = true;
85 this.#controls.dampingFactor = 0.1;
86 // Fires on user input and on every damping-tail update, so the flag stays
87 // set until the camera has fully settled.
88 this.#controls.addEventListener('change', () => {
89 this.#needsRender = true;
90 });
92 this.#animate();
93 }
95 #animate = () => {
96 this.#animationId = requestAnimationFrame(this.#animate);
97 this.#controls.update();
98 if (!this.#needsRender) return;
99 this.#needsRender = false;
100 this.#renderer.render(this.#scene, this.#camera);
101 };
103 updateColors(colors: Float32Array): void {
104 const attr = this.#geometry.getAttribute('color') as THREE.BufferAttribute;
105 (attr.array as Float32Array).set(colors);
106 attr.needsUpdate = true;
107 this.#needsRender = true;
108 }
110 /** The renderer's canvas, for capturing frames. */
111 get canvas(): HTMLCanvasElement {
112 return this.#renderer.domElement;
113 }
115 /**
116 * Render immediately, outside the animation loop. A WebGL canvas without
117 * preserveDrawingBuffer keeps its drawing buffer only until the browser next
118 * composites, so a capturer must render and copy within one task.
119 */
120 renderNow(): void {
121 this.#needsRender = false;
122 this.#renderer.render(this.#scene, this.#camera);
123 }
125 /** Mirror this scene's camera whenever the other scene's controls move. */
126 syncCamerasWith(other: SphereScene): void {
127 const follow = (src: SphereScene, dst: SphereScene) => {
128 src.#controls.addEventListener('change', () => {
129 if (dst.#syncing) return;
130 src.#syncing = true;
131 dst.#camera.position.copy(src.#camera.position);
132 dst.#camera.zoom = src.#camera.zoom;
133 dst.#camera.updateProjectionMatrix();
134 dst.#controls.target.copy(src.#controls.target);
135 dst.#controls.update();
136 dst.#needsRender = true;
137 src.#syncing = false;
138 });
139 };
140 follow(this, other);
141 follow(other, this);
142 }
144 /** Orbit the camera about the up axis by `angle` radians, keeping the
145 * target. Synced sibling scenes follow via their controls, as with a drag. */
146 orbitBy(angle: number): void {
147 const offset = this.#camera.position.clone().sub(this.#controls.target);
148 offset.applyAxisAngle(this.#camera.up, angle);
149 this.#camera.position.copy(this.#controls.target).add(offset);
150 this.#controls.update();
151 this.#needsRender = true;
152 }
154 /** Camera pose, for carrying the view across a scene rebuild. */
155 cameraState(): { position: THREE.Vector3; target: THREE.Vector3; zoom: number } {
156 return {
157 position: this.#camera.position.clone(),
158 target: this.#controls.target.clone(),
159 zoom: this.#camera.zoom,
160 };
161 }
163 setCameraState(s: {
164 position: THREE.Vector3;
165 target: THREE.Vector3;
166 zoom: number;
167 }): void {
168 this.#camera.position.copy(s.position);
169 this.#camera.zoom = s.zoom;
170 this.#camera.updateProjectionMatrix();
171 this.#controls.target.copy(s.target);
172 this.#controls.update();
173 this.#needsRender = true;
174 }
176 /** Position the camera to comfortably frame the geometry. */
177 fitCamera(): void {
178 this.#geometry.computeBoundingSphere();
179 const bs = this.#geometry.boundingSphere;
180 if (!bs) return;
181 const radius = Math.max(bs.radius, 1e-6);
182 const distance = radius * 2.6;
183 this.#controls.target.copy(bs.center);
184 this.#camera.position.set(
185 bs.center.x + distance * 0.55,
186 bs.center.y + distance * 0.35,
187 bs.center.z + distance * 0.75,
188 );
189 this.#camera.near = radius * 0.01;
190 this.#camera.far = radius * 100;
191 this.#camera.updateProjectionMatrix();
192 this.#controls.update();
193 this.#needsRender = true;
194 this.#defaultCameraState = {
195 position: this.#camera.position.clone(),
196 target: this.#controls.target.clone(),
197 };
198 }
200 resetCamera(): void {
201 if (this.#defaultCameraState) {
202 this.#camera.position.copy(this.#defaultCameraState.position);
203 this.#controls.target.copy(this.#defaultCameraState.target);
204 this.#controls.update();
205 this.#needsRender = true;
206 } else {
207 this.fitCamera();
208 }
209 }
211 resize(width: number, height: number): void {
212 // Setting canvas.width clears the canvas even at the same value, which
213 // shows as a blank flash until the next render — skip no-op resizes.
214 if (width === this.#lastW && height === this.#lastH) return;
215 this.#lastW = width;
216 this.#lastH = height;
217 this.#camera.aspect = width / Math.max(1, height);
218 this.#camera.updateProjectionMatrix();
219 // updateStyle=false: the canvas keeps its 100%/100% CSS sizing
220 this.#renderer.setSize(width, height, false);
221 // setSize clears the drawing buffer, so a re-render is required even
222 // though nothing in the scene moved
223 this.#needsRender = true;
224 }
226 /**
227 * Set the drawing buffer to an exact square pixel size, independent of the
228 * container and devicePixelRatio — for capturing at a chosen resolution.
229 * The canvas keeps its CSS sizing, so on screen it just rescales. Undo with
230 * restoreSize().
231 */
232 captureSize(px: number): void {
233 this.#renderer.setPixelRatio(1);
234 this.#renderer.setSize(px, px, false);
235 this.#camera.aspect = 1;
236 this.#camera.updateProjectionMatrix();
237 this.#needsRender = true;
238 }
240 /** Return from captureSize() to the container-driven buffer size. */
241 restoreSize(): void {
242 this.#renderer.setPixelRatio(window.devicePixelRatio || 1);
243 if (this.#lastW > 0 && this.#lastH > 0) {
244 this.#renderer.setSize(this.#lastW, this.#lastH, false);
245 this.#camera.aspect = this.#lastW / Math.max(1, this.#lastH);
246 this.#camera.updateProjectionMatrix();
247 }
248 this.#needsRender = true;
249 }
251 dispose(): void {
252 if (this.#animationId !== null) {
253 cancelAnimationFrame(this.#animationId);
254 this.#animationId = null;
255 }
256 this.#controls.dispose();
257 this.#geometry.dispose();
258 (this.#mesh.material as THREE.Material).dispose();
259 if (this.#renderer.domElement.parentNode) {
260 this.#renderer.domElement.parentNode.removeChild(
261 this.#renderer.domElement,
262 );
263 }
264 this.#renderer.dispose();
265 }