Skip to content

Commit d6898b5

Browse files
authored
Splat brush painting / brush erasing example (#165)
* splat painter example * avoid additional updategenerator calls
1 parent c5f6156 commit d6898b5

2 files changed

Lines changed: 372 additions & 0 deletions

File tree

examples/assets.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@
8787
"url": "https://sparkjs.dev/assets/splats/penguin.spz",
8888
"directory": "splats"
8989
},
90+
"painted-bedroom.spz": {
91+
"url": "https://storage.googleapis.com/forge-dev-public/painted_bedroom.spz",
92+
"directory": "splats"
93+
},
9094
"primerib-tamos.spz": {
9195
"url": "https://sparkjs.dev/assets/splats/food/primerib-tamos.spz",
9296
"directory": "splats/food"

examples/splat-painter/index.html

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Spark • Splat Painter</title>
7+
<style>
8+
html, body {
9+
margin: 0;
10+
height: 100%;
11+
width: 100%;
12+
background-color: black;
13+
}
14+
#canvas {
15+
position: absolute;
16+
top: 0;
17+
left: 0;
18+
width: 100%;
19+
height: 100%;
20+
outline: none; /* Remove default focus outline */
21+
touch-action: none;
22+
}
23+
#mode-overlay {
24+
position: absolute;
25+
top: 20px;
26+
left: 20px;
27+
background: rgba(0, 0, 0, 0.8);
28+
color: white;
29+
padding: 12px 20px;
30+
border-radius: 8px;
31+
font-family: sans-serif;
32+
font-size: 16px;
33+
font-weight: bold;
34+
z-index: 1000;
35+
opacity: 0;
36+
transition: opacity 0.3s ease;
37+
pointer-events: none;
38+
}
39+
#mode-overlay.show {
40+
opacity: 1;
41+
}
42+
</style>
43+
</head>
44+
<body>
45+
<canvas id="canvas" tabindex="0"></canvas>
46+
<div id="mode-overlay"></div>
47+
<script type="importmap">
48+
{
49+
"imports": {
50+
"three": "/examples/js/vendor/three/build/three.module.js",
51+
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
52+
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
53+
"@sparkjsdev/spark": "/dist/spark.module.js"
54+
}
55+
}
56+
</script>
57+
<script type="module">
58+
import {
59+
dyno,
60+
SparkRenderer,
61+
SplatMesh,
62+
SplatTransformer,
63+
SparkControls,
64+
transcodeSpz,
65+
} from "@sparkjsdev/spark";
66+
import * as THREE from "three";
67+
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
68+
import { GUI } from "lil-gui";
69+
70+
71+
const PARAMETERS = {
72+
controlsEnabled: true,
73+
eraseEnabled: dyno.dynoBool(false),
74+
brushEnabled: dyno.dynoBool(false),
75+
brushDepth: dyno.dynoFloat(10.0),
76+
brushRadius: dyno.dynoFloat(0.05),
77+
brushOrigin: dyno.dynoVec3(new THREE.Vector3(0.0, 0.0, 0.0)),
78+
brushDirection: dyno.dynoVec3(new THREE.Vector3(0.0, 0.0, 0.0)),
79+
brushColorHex: "#ff00ff",
80+
brushColor: dyno.dynoVec3(new THREE.Vector3(1.0, 0.0, 1.0)),
81+
};
82+
83+
const MIN_BRUSH_RADIUS = 0.01;
84+
const MAX_BRUSH_RADIUS = 0.25;
85+
const MIN_BRUSH_DEPTH = 0.1;
86+
const MAX_BRUSH_DEPTH = 100.0;
87+
88+
const assetID = "painted-bedroom.spz";
89+
let currentSplatMesh = null;
90+
91+
function brushDyno(
92+
brushEnabled,
93+
eraseEnabled,
94+
brushRadius,
95+
brushDepth,
96+
brushOrigin,
97+
brushDirection,
98+
brushColor,
99+
) {
100+
return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => {
101+
if (!gsplat) {
102+
throw new Error("No gsplat input");
103+
}
104+
let { center, rgb, opacity } = dyno.splitGsplat(gsplat).outputs;
105+
const projectionAmplitude = dyno.dot(brushDirection, dyno.sub(center, brushOrigin));
106+
const projectedCenter = dyno.add(brushOrigin, dyno.mul(brushDirection, projectionAmplitude));
107+
const distance = dyno.length(dyno.sub(projectedCenter, center)); // distance from projected center to actual center
108+
const isInside = dyno.and(dyno.lessThan(distance, brushRadius),
109+
dyno.and(dyno.greaterThan(projectionAmplitude, dyno.dynoFloat(0.0)),
110+
dyno.lessThan(projectionAmplitude, brushDepth)));
111+
const newRgb = dyno.select(brushEnabled, dyno.select(isInside, brushColor, rgb), rgb);
112+
const newOpacity = dyno.select(eraseEnabled, dyno.select(isInside, dyno.dynoFloat(0.0), opacity), opacity);
113+
gsplat = dyno.combineGsplat({ gsplat, rgb: newRgb, opacity: newOpacity });
114+
return { gsplat };
115+
});
116+
}
117+
118+
function paintableSplatMesh(
119+
url,
120+
brushEnabled,
121+
eraseEnabled,
122+
brushRadius,
123+
brushDepth,
124+
brushOrigin,
125+
brushDirection,
126+
brushColor,
127+
) {
128+
const splatMesh = new SplatMesh({
129+
url: url,
130+
onFrame: ({ mesh, time }) => {
131+
mesh.needsUpdate = true;
132+
}
133+
});
134+
splatMesh.worldModifier = brushDyno(
135+
brushEnabled,
136+
eraseEnabled,
137+
brushRadius,
138+
brushDepth,
139+
brushOrigin,
140+
brushDirection,
141+
brushColor,
142+
);
143+
splatMesh.updateGenerator();
144+
return splatMesh;
145+
}
146+
147+
const canvas = document.getElementById("canvas");
148+
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false });
149+
renderer.setClearColor(new THREE.Color(0xaaffff), 1);
150+
151+
const scene = new THREE.Scene();
152+
const spark = new SparkRenderer({
153+
renderer,
154+
});
155+
scene.add(spark);
156+
const controls = new SparkControls({
157+
canvas: renderer.domElement,
158+
});
159+
160+
const camera = new THREE.PerspectiveCamera(
161+
50,
162+
window.innerWidth / window.innerHeight,
163+
0.01,
164+
1000,
165+
);
166+
camera.position.set(0, 0, 0);
167+
camera.lookAt(0, 0, -1);
168+
scene.add(camera);
169+
170+
function handleResize() {
171+
const width = canvas.clientWidth;
172+
const height = canvas.clientHeight;
173+
renderer.setSize(width, height, false);
174+
camera.aspect = width / height;
175+
camera.updateProjectionMatrix();
176+
}
177+
178+
handleResize();
179+
window.addEventListener("resize", handleResize);
180+
181+
async function loadSplatFromFile(url) {
182+
if (currentSplatMesh) {
183+
scene.remove(currentSplatMesh);
184+
}
185+
currentSplatMesh = await paintableSplatMesh(
186+
url,
187+
PARAMETERS.brushEnabled,
188+
PARAMETERS.eraseEnabled,
189+
PARAMETERS.brushRadius,
190+
PARAMETERS.brushDepth,
191+
PARAMETERS.brushOrigin,
192+
PARAMETERS.brushDirection,
193+
PARAMETERS.brushColor
194+
);
195+
currentSplatMesh.quaternion.set(1, 0, 0, 0);
196+
scene.add(currentSplatMesh);
197+
198+
}
199+
200+
await loadSplatFromFile(await getAssetFileURL(assetID));
201+
202+
const raycaster = new THREE.Raycaster();
203+
let isDragging = false;
204+
205+
// Mode overlay functionality
206+
const modeOverlay = document.getElementById('mode-overlay');
207+
let overlayTimeout;
208+
209+
function showModeOverlay(text) {
210+
modeOverlay.textContent = text;
211+
modeOverlay.classList.add('show');
212+
213+
// Clear any existing timeout
214+
if (overlayTimeout) {
215+
clearTimeout(overlayTimeout);
216+
}
217+
218+
// Hide overlay after 2 seconds
219+
overlayTimeout = setTimeout(() => {
220+
modeOverlay.classList.remove('show');
221+
}, 2000);
222+
}
223+
224+
renderer.domElement.addEventListener('pointermove', (event) => {
225+
const clickCoords = new THREE.Vector2(
226+
(event.clientX / renderer.domElement.width) * 2 - 1,
227+
-(event.clientY / renderer.domElement.height) * 2 + 1,
228+
);
229+
raycaster.setFromCamera(clickCoords, camera);
230+
const direction = raycaster.ray.direction.normalize();
231+
PARAMETERS.brushDirection.value.x = direction.x;
232+
PARAMETERS.brushDirection.value.y = direction.y;
233+
PARAMETERS.brushDirection.value.z = direction.z;
234+
PARAMETERS.brushOrigin.value.x = raycaster.ray.origin.x;
235+
PARAMETERS.brushOrigin.value.y = raycaster.ray.origin.y;
236+
PARAMETERS.brushOrigin.value.z = raycaster.ray.origin.z;
237+
238+
// Apply painting effect while dragging
239+
if (isDragging && currentSplatMesh) {
240+
const noSplatRgba = !currentSplatMesh.splatRgba;
241+
currentSplatMesh.splatRgba = spark.getRgba(
242+
{ generator: currentSplatMesh , rgba: currentSplatMesh.splatRgba}
243+
);
244+
if (noSplatRgba) {
245+
currentSplatMesh.updateGenerator();
246+
} else {
247+
currentSplatMesh.updateVersion();
248+
}
249+
}
250+
});
251+
252+
renderer.domElement.addEventListener('pointerdown', (event) => {
253+
isDragging = true;
254+
if (currentSplatMesh) {
255+
const noSplatRgba = !currentSplatMesh.splatRgba;
256+
currentSplatMesh.splatRgba = spark.getRgba(
257+
{ generator: currentSplatMesh , rgba: currentSplatMesh.splatRgba}
258+
);
259+
if (noSplatRgba) {
260+
currentSplatMesh.updateGenerator();
261+
} else {
262+
currentSplatMesh.updateVersion();
263+
}
264+
}
265+
});
266+
267+
renderer.domElement.addEventListener('pointerup', (event) => {
268+
isDragging = false;
269+
});
270+
271+
renderer.domElement.addEventListener('pointerleave', (event) => {
272+
isDragging = false;
273+
});
274+
275+
const gui = new GUI();
276+
277+
// Instructions section
278+
const instructions = {
279+
brush: "Brush Mode",
280+
erase: "Erase Mode",
281+
none: "View Mode",
282+
increase: "Increase Brush Size",
283+
decrease: "Decrease Brush Size",
284+
increaseDepth: "Increase Brush Depth",
285+
decreaseDepth: "Decrease Brush Depth"
286+
};
287+
const instructionsFolder = gui.addFolder("Instructions");
288+
instructionsFolder.add(instructions, "brush").name("1:").disable();
289+
instructionsFolder.add(instructions, "erase").name("2:").disable();
290+
instructionsFolder.add(instructions, "none").name("Esc:").disable();
291+
instructionsFolder.add(instructions, "increase").name("=:").disable();
292+
instructionsFolder.add(instructions, "decrease").name("-:").disable();
293+
instructionsFolder.add(instructions, "increaseDepth").name("]:").disable();
294+
instructionsFolder.add(instructions, "decreaseDepth").name("[:").disable();
295+
instructionsFolder.open();
296+
const brushRadiusController = gui.add(PARAMETERS.brushRadius, "value", MIN_BRUSH_RADIUS, MAX_BRUSH_RADIUS, 0.01).name("Brush Radius");
297+
const brushDepthController = gui.add(PARAMETERS.brushDepth, "value", MIN_BRUSH_DEPTH, MAX_BRUSH_DEPTH, 0.1).name("Brush Depth");
298+
gui.addColor(PARAMETERS, "brushColorHex").name("Brush Color").onChange((value) => {
299+
PARAMETERS.brushColor.value = new THREE.Color(value).convertLinearToSRGB();
300+
console.log(PARAMETERS.brushColor.value);
301+
});
302+
303+
// Keyboard controls
304+
window.addEventListener('keydown', (event) => {
305+
if (event.key === '1') {
306+
// Brush mode
307+
PARAMETERS.brushEnabled.value = true;
308+
PARAMETERS.eraseEnabled.value = false;
309+
PARAMETERS.controlsEnabled = false;
310+
controls.enabled = false;
311+
showModeOverlay('Paint Mode');
312+
} else if (event.key === '2') {
313+
// Eraser mode
314+
PARAMETERS.brushEnabled.value = false;
315+
PARAMETERS.eraseEnabled.value = true;
316+
PARAMETERS.controlsEnabled = false;
317+
controls.enabled = false;
318+
showModeOverlay('Erase Mode');
319+
} else if (event.key === 'Escape') {
320+
// View mode
321+
PARAMETERS.brushEnabled.value = false;
322+
PARAMETERS.eraseEnabled.value = false;
323+
PARAMETERS.controlsEnabled = true;
324+
controls.enabled = true;
325+
showModeOverlay('View Mode');
326+
} else if (event.key === '=' || event.key === '+') {
327+
// Increase brush radius
328+
const currentRadius = PARAMETERS.brushRadius.value;
329+
const newRadius = Math.min(currentRadius + 0.01, MAX_BRUSH_RADIUS);
330+
PARAMETERS.brushRadius.value = newRadius;
331+
brushRadiusController.updateDisplay();
332+
} else if (event.key === '-' || event.key === '_') {
333+
// Decrease brush radius
334+
const currentRadius = PARAMETERS.brushRadius.value;
335+
const newRadius = Math.max(currentRadius - 0.01, MIN_BRUSH_RADIUS);
336+
PARAMETERS.brushRadius.value = newRadius;
337+
brushRadiusController.updateDisplay();
338+
} else if (event.key === ']') {
339+
// Increase brush depth
340+
const currentDepth = PARAMETERS.brushDepth.value;
341+
const newDepth = Math.min(currentDepth + 0.5, MAX_BRUSH_DEPTH);
342+
PARAMETERS.brushDepth.value = newDepth;
343+
brushDepthController.updateDisplay();
344+
} else if (event.key === '[') {
345+
// Decrease brush depth
346+
const currentDepth = PARAMETERS.brushDepth.value;
347+
const newDepth = Math.max(currentDepth - 0.5, MIN_BRUSH_DEPTH);
348+
PARAMETERS.brushDepth.value = newDepth;
349+
brushDepthController.updateDisplay();
350+
}
351+
});
352+
353+
console.log("Starting render loop");
354+
355+
// Animation loop
356+
let lastTime = 0;
357+
renderer.setAnimationLoop((rawTime) => {
358+
rawTime *= 0.0005;
359+
const deltaTime = rawTime - (lastTime ?? rawTime);
360+
lastTime = rawTime;
361+
if (PARAMETERS.controlsEnabled) {
362+
controls.update(camera);
363+
}
364+
renderer.render(scene, camera);
365+
});
366+
</script>
367+
</body>
368+
</html>

0 commit comments

Comments
 (0)