/ simulated_instruments / bass
Sign in
simulated_instruments / bass
bass / webapp / src / components / Scene3D.tsx
315 lines · 11.7 KBCodeBlameHistory
0e92d35Pizzicato standup bass: coupled string-body modal model, numbl MATLAB reference, and interactive web appJeremy Magland 1// 3D view of the geometry: a stylized double bass standing on its endpin,
2// the plucking finger at the pluck point of the first note's string, the
3// stopping finger when that note is fingered, and the listener. Model
4// coordinates put the origin at the bridge, +y up the neck, and +z out of
5// the top plate; three.js is y-up too, so the mapping is the identity. The
6// body outline is a visual stand-in: the acoustics use only the modal table
7// of bass_body.m, the two f-hole points, and the plate-normal dipole.
9import { useEffect, useRef } from 'react';
10import * as THREE from 'three';
11import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
12import type { BassParams } from '../params';
14function makeLabel(text: string, color: string, scale = 1): THREE.Sprite {
15 const canvas = document.createElement('canvas');
16 canvas.width = 320;
17 canvas.height = 64;
18 const ctx = canvas.getContext('2d')!;
19 ctx.font = '600 36px system-ui, sans-serif';
20 ctx.fillStyle = color;
21 ctx.textAlign = 'center';
22 ctx.textBaseline = 'middle';
23 ctx.fillText(text, 160, 32);
24 const tex = new THREE.CanvasTexture(canvas);
25 const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true });
26 const sprite = new THREE.Sprite(mat);
27 sprite.scale.set(0.24 * scale, 0.048 * scale, 1);
28 return sprite;
31// The fingering of the first note, mirroring bass_notes.m: the highest-tuned
32// string whose open pitch does not exceed the note.
33function fingeringOf(rootMidi: number): { str: number; stop: number } {
34 const open = [43, 38, 33, 28];
35 const m = Math.min(55, Math.max(28, Math.round(rootMidi)));
36 for (let s = 0; s < 4; s++) {
37 if (open[s] <= m) return { str: s, stop: m - open[s] };
38 }
39 return { str: 3, stop: 0 };
42// Right half of the body outline (gamba-ish sloped shoulders), mirrored.
43function bodyShape(): THREE.Shape {
44 const sh = new THREE.Shape();
45 sh.moveTo(0, -0.61);
46 sh.bezierCurveTo(0.24, -0.61, 0.345, -0.48, 0.33, -0.30);
47 sh.bezierCurveTo(0.315, -0.16, 0.245, -0.105, 0.205, -0.06);
48 sh.bezierCurveTo(0.158, -0.005, 0.158, 0.10, 0.20, 0.155);
49 sh.bezierCurveTo(0.238, 0.205, 0.258, 0.245, 0.245, 0.30);
50 sh.bezierCurveTo(0.222, 0.40, 0.125, 0.475, 0, 0.50);
51 sh.bezierCurveTo(-0.125, 0.475, -0.222, 0.40, -0.245, 0.30);
52 sh.bezierCurveTo(-0.258, 0.245, -0.238, 0.205, -0.20, 0.155);
53 sh.bezierCurveTo(-0.158, 0.10, -0.158, -0.005, -0.205, -0.06);
54 sh.bezierCurveTo(-0.245, -0.105, -0.315, -0.16, -0.33, -0.30);
55 sh.bezierCurveTo(-0.345, -0.48, -0.24, -0.61, 0, -0.61);
56 return sh;
59interface Props {
60 params: BassParams;
63export default function Scene3D({ params }: Props) {
64 const mountRef = useRef<HTMLDivElement | null>(null);
65 const sceneRef = useRef<{
66 scene: THREE.Scene;
67 group: THREE.Group | null;
68 renderer: THREE.WebGLRenderer;
69 camera: THREE.PerspectiveCamera;
70 } | null>(null);
72 // one-time scene setup
73 useEffect(() => {
74 const mount = mountRef.current!;
75 const renderer = new THREE.WebGLRenderer({ antialias: true });
76 renderer.setPixelRatio(window.devicePixelRatio);
77 renderer.setClearColor(0x14161a);
78 mount.appendChild(renderer.domElement);
80 const scene = new THREE.Scene();
81 const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 40);
82 camera.position.set(1.35, 1.05, 2.3);
84 const controls = new OrbitControls(camera, renderer.domElement);
85 controls.target.set(0, 0.35, 0);
86 controls.enableDamping = true;
88 scene.add(new THREE.AmbientLight(0xffffff, 0.5));
89 const dir = new THREE.DirectionalLight(0xffffff, 1.1);
90 dir.position.set(1.2, 2.2, 1.6);
91 scene.add(dir);
92 const dir2 = new THREE.DirectionalLight(0x8899ff, 0.3);
93 dir2.position.set(-1.4, 0.8, -1.2);
94 scene.add(dir2);
96 const grid = new THREE.GridHelper(3.2, 32, 0x3a4048, 0x262b33);
97 grid.position.y = -0.78;
98 scene.add(grid);
100 // model axes: x red (across), y green (up the neck), z blue (plate normal)
101 const axisLen = 0.22;
102 const axes: [THREE.Vector3, number, string][] = [
103 [new THREE.Vector3(axisLen, 0, 0), 0xe57373, '+x'],
104 [new THREE.Vector3(0, axisLen, 0), 0x81c784, '+y'],
105 [new THREE.Vector3(0, 0, axisLen), 0x64b5f6, '+z'],
106 ];
107 for (const [v, color, label] of axes) {
108 const geo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), v]);
109 scene.add(new THREE.Line(geo, new THREE.LineBasicMaterial({ color })));
110 const s = makeLabel(label, '#' + color.toString(16).padStart(6, '0'), 0.6);
111 s.position.copy(v).multiplyScalar(1.15);
112 scene.add(s);
113 }
115 sceneRef.current = { scene, group: null, renderer, camera };
117 let raf = 0;
118 const animate = () => {
119 raf = requestAnimationFrame(animate);
120 controls.update();
121 renderer.render(scene, camera);
122 };
123 animate();
125 const resize = () => {
126 const w = mount.clientWidth;
127 const h = mount.clientHeight;
128 renderer.setSize(w, h, false);
129 camera.aspect = w / h;
130 camera.updateProjectionMatrix();
131 };
132 resize();
133 const ro = new ResizeObserver(resize);
134 ro.observe(mount);
136 return () => {
137 cancelAnimationFrame(raf);
138 ro.disconnect();
139 controls.dispose();
140 renderer.dispose();
141 mount.removeChild(renderer.domElement);
142 sceneRef.current = null;
143 };
144 }, []);
146 // rebuild the geometry group whenever relevant parameters change
147 useEffect(() => {
148 const ctx = sceneRef.current;
149 if (!ctx) return;
150 if (ctx.group) {
151 ctx.scene.remove(ctx.group);
152 ctx.group.traverse((o) => {
153 const mesh = o as THREE.Mesh;
154 if (mesh.geometry) mesh.geometry.dispose();
155 });
156 }
158 const g = new THREE.Group();
159 const yNut = params.scale_length_m;
160 const spruce = new THREE.MeshStandardMaterial({ color: 0xc79b56, roughness: 0.55, metalness: 0.05 });
161 const maple = new THREE.MeshStandardMaterial({ color: 0x8a5a2b, roughness: 0.6, metalness: 0.05 });
162 const ebony = new THREE.MeshStandardMaterial({ color: 0x17130f, roughness: 0.45, metalness: 0.1 });
164 // body: extruded outline, top plate at z ~ +0.11
165 const body = new THREE.Mesh(
166 new THREE.ExtrudeGeometry(bodyShape(), {
167 depth: 0.2,
168 bevelEnabled: true,
169 bevelThickness: 0.006,
170 bevelSize: 0.006,
171 bevelSegments: 2,
172 curveSegments: 24,
173 }),
174 spruce,
175 );
176 body.position.z = -0.106;
177 g.add(body);
179 // f-holes: dark slots flanking the bridge (the monopole points sit here)
180 for (const sx of [-1, 1]) {
181 const fh = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.17, 0.004), ebony);
182 fh.position.set(sx * 0.095, -0.02, 0.111);
183 fh.rotation.z = sx * 0.18;
184 g.add(fh);
185 }
187 // neck, fingerboard, pegbox, scroll hint
188 const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.024, 0.03, yNut - 0.42, 12), maple);
189 neck.position.set(0, 0.44 + (yNut - 0.42) / 2, 0.075);
190 g.add(neck);
191 const fbLen = yNut - 0.28;
192 const fb = new THREE.Mesh(new THREE.BoxGeometry(0.075, fbLen, 0.016), ebony);
193 fb.position.set(0, 0.28 + fbLen / 2, 0.118);
194 g.add(fb);
195 const pegbox = new THREE.Mesh(new THREE.BoxGeometry(0.055, 0.17, 0.05), maple);
196 pegbox.position.set(0, yNut + 0.085, 0.095);
197 g.add(pegbox);
198 const scroll = new THREE.Mesh(new THREE.TorusGeometry(0.032, 0.014, 8, 20), maple);
199 scroll.position.set(0, yNut + 0.185, 0.095);
200 g.add(scroll);
202 // bridge, tailpiece, endpin
203 const bridge = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.012, 0.075), maple);
204 bridge.position.set(0, 0, 0.148);
205 g.add(bridge);
206 const tail = new THREE.Mesh(new THREE.BoxGeometry(0.085, 0.22, 0.018), ebony);
207 tail.position.set(0, -0.46, 0.128);
208 g.add(tail);
209 const endpin = new THREE.Mesh(new THREE.CylinderGeometry(0.006, 0.006, 0.17, 8),
210 new THREE.MeshStandardMaterial({ color: 0x777d88, roughness: 0.4, metalness: 0.8 }));
211 endpin.position.set(0, -0.695, 0);
212 g.add(endpin);
214 // strings: G D A E left to right at the bridge, converging at the nut
215 const bridgeX = [-0.045, -0.015, 0.015, 0.045];
216 const nutX = [-0.0165, -0.0055, 0.0055, 0.0165];
217 const rad = [0.0011, 0.0013, 0.0016, 0.002];
218 const zBridge = 0.187;
219 const zNut = 0.132;
220 const zTail = 0.135;
221 const steel = new THREE.MeshStandardMaterial({ color: 0xb8bec8, roughness: 0.35, metalness: 0.85 });
222 const addSeg = (a: THREE.Vector3, b: THREE.Vector3, r: number) => {
223 const len = a.distanceTo(b);
224 const cyl = new THREE.Mesh(new THREE.CylinderGeometry(r, r, len, 8), steel);
225 cyl.position.copy(a).add(b).multiplyScalar(0.5);
226 cyl.quaternion.setFromUnitVectors(
227 new THREE.Vector3(0, 1, 0),
228 b.clone().sub(a).normalize(),
229 );
230 g.add(cyl);
231 };
232 for (let s = 0; s < 4; s++) {
233 const pB = new THREE.Vector3(bridgeX[s], 0, zBridge);
234 const pN = new THREE.Vector3(nutX[s], yNut, zNut);
235 const pT = new THREE.Vector3(bridgeX[s] * 0.6, -0.42, zTail);
236 addSeg(pB, pN, rad[s]);
237 addSeg(pT, pB, rad[s]);
238 }
240 // the first note: plucking finger at the pluck point, stopping finger at
241 // the stop (both on the string bass_notes.m would choose for the root)
242 const { str, stop } = fingeringOf(params.root_midi);
243 const Lv = yNut * 2 ** (-stop / 12);
244 const along = (t: number) =>
245 new THREE.Vector3(
246 bridgeX[str] + (nutX[str] - bridgeX[str]) * t,
247 yNut * t,
248 zBridge + (zNut - zBridge) * t,
249 );
250 const tPluck = (params.pluck_pos * Lv) / yNut;
251 const pluck = new THREE.Mesh(
252 new THREE.SphereGeometry(0.016, 16, 12),
253 new THREE.MeshStandardMaterial({ color: 0xe8a33d, roughness: 0.4 }),
254 );
255 pluck.position.copy(along(tPluck)).add(new THREE.Vector3(0, 0, 0.012));
256 g.add(pluck);
257 const pluckLbl = makeLabel('pluck', '#e8a33d', 0.7);
258 pluckLbl.position.copy(pluck.position).add(new THREE.Vector3(0.05, 0.045, 0.03));
259 g.add(pluckLbl);
260 if (stop > 0) {
261 const fingerPos = along(Lv / yNut);
262 const finger = new THREE.Mesh(
263 new THREE.SphereGeometry(0.013, 16, 12),
264 new THREE.MeshStandardMaterial({ color: 0xd98a7a, roughness: 0.6 }),
265 );
266 finger.position.copy(fingerPos).add(new THREE.Vector3(0, 0, 0.01));
267 g.add(finger);
268 const stopLbl = makeLabel('stop', '#d98a7a', 0.6);
269 stopLbl.position.copy(fingerPos).add(new THREE.Vector3(-0.05, 0.045, 0.03));
270 g.add(stopLbl);
271 }
273 // listener: microphone marker + line from the bridge
274 const lis = new THREE.Vector3(params.listener_x_m, params.listener_y_m, params.listener_z_m);
275 const micHead = new THREE.Mesh(
276 new THREE.SphereGeometry(0.035, 20, 16),
277 new THREE.MeshStandardMaterial({ color: 0x4dd0e1, emissive: 0x0c3b42, roughness: 0.35 }),
278 );
279 micHead.position.copy(lis);
280 g.add(micHead);
281 const micBody = new THREE.Mesh(
282 new THREE.CylinderGeometry(0.016, 0.016, 0.1, 12),
283 new THREE.MeshStandardMaterial({ color: 0x455a64, roughness: 0.6 }),
284 );
285 micBody.position.copy(lis).add(new THREE.Vector3(0, -0.068, 0));
286 g.add(micBody);
287 const lineGeo = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0.12), lis]);
288 const line = new THREE.Line(
289 lineGeo,
290 new THREE.LineDashedMaterial({ color: 0x4dd0e1, dashSize: 0.03, gapSize: 0.022 }),
291 );
292 line.computeLineDistances();
293 g.add(line);
294 const dist = lis.length();
295 const micLabel = makeLabel(`mic ${(dist * 100).toFixed(0)} cm`, '#4dd0e1', 0.8);
296 micLabel.position.copy(lis).add(new THREE.Vector3(0, 0.1, 0));
297 g.add(micLabel);
299 ctx.scene.add(g);
300 ctx.group = g;
301 }, [
302 params.scale_length_m,
303 params.root_midi,
304 params.pluck_pos,
305 params.listener_x_m,
306 params.listener_y_m,
307 params.listener_z_m,
308 ]);
310 return (
311 <div className="scene3d" ref={mountRef}>
312 <div className="scene-hint">drag to orbit · scroll to zoom · right-drag to pan</div>
313 </div>
314 );
↑↓ move↵openescclose