Files
spark/src/splatConstructors.ts
T
2025-05-27 08:40:58 -07:00

420 lines
14 KiB
TypeScript

import * as THREE from "three";
import { PackedSplats } from "./PackedSplats";
import { SplatMesh } from "./SplatMesh";
export function constructGrid({
// PackedSplats object to add splats to
splats,
// min and max box extents of the grid
extents,
// step size along each grid axis
stepSize = 1,
// spherical radius of each Gsplat
pointRadius = 0.01,
// relative size of the "shadow copy" of each Gsplat placed behind it
pointShadowScale = 2.0,
// Gsplat opacity
opacity = 1.0,
// Gsplat color (THREE.Color) or function to set color for position:
// ((THREE.Color, THREE.Vector3) => void) (default: RGB-modulated grid)
color,
}: {
splats: PackedSplats;
extents: THREE.Box3;
stepSize?: number;
pointRadius?: number;
pointShadowScale?: number;
opacity?: number;
color?: THREE.Color | ((color: THREE.Color, point: THREE.Vector3) => void);
}) {
const EPSILON = 1.0e-6;
const center = new THREE.Vector3();
const scales = new THREE.Vector3();
const quaternion = new THREE.Quaternion(0, 0, 0, 1);
if (color == null) {
color = (color, point) =>
color.set(
0.55 + 0.45 * Math.cos(point.x * 1),
0.55 + 0.45 * Math.cos(point.y * 1),
0.55 + 0.45 * Math.cos(point.z * 1),
);
}
const pointColor = new THREE.Color();
for (let z = extents.min.z; z < extents.max.z + EPSILON; z += stepSize) {
for (let y = extents.min.y; y < extents.max.y + EPSILON; y += stepSize) {
for (let x = extents.min.x; x < extents.max.x + EPSILON; x += stepSize) {
center.set(x, y, z);
for (let layer = 0; layer < 2; ++layer) {
scales.setScalar(pointRadius * (layer ? 1 : pointShadowScale));
if (!layer) {
pointColor.setScalar(0.0);
} else if (typeof color === "function") {
color(pointColor, center);
} else {
pointColor.copy(color);
}
splats.pushSplat(center, scales, quaternion, opacity, pointColor);
}
}
}
}
}
export function constructAxes({
// PackedSplats object to add splats to
splats,
// scale (Gsplat scale along axis)
scale = 0.25,
// radius of the axes (Gsplat scale orthogonal to axis)
axisRadius = 0.0075,
// relative size of the "shadow copy" of each Gsplat placed behind it
axisShadowScale = 2.0,
// origins of the axes (default single axis at origin)
origins = [new THREE.Vector3()],
}: {
splats: PackedSplats;
scale?: number;
axisRadius?: number;
axisShadowScale?: number;
origins?: THREE.Vector3[];
}) {
const center = new THREE.Vector3();
const scales = new THREE.Vector3();
const quaternion = new THREE.Quaternion(0, 0, 0, 1);
const color = new THREE.Color();
const opacity = 1.0;
for (const origin of origins) {
for (let axis = 0; axis < 3; ++axis) {
center.set(
origin.x + (axis === 0 ? scale : 0),
origin.y + (axis === 1 ? scale : 0),
origin.z + (axis === 2 ? scale : 0),
);
for (let layer = 0; layer < 2; ++layer) {
scales.set(
(axis === 0 ? scale : axisRadius) * (layer ? 1 : axisShadowScale),
(axis === 1 ? scale : axisRadius) * (layer ? 1 : axisShadowScale),
(axis === 2 ? scale : axisRadius) * (layer ? 1 : axisShadowScale),
);
color.setRGB(
layer === 0 ? 0.0 : axis === 0 ? 1.0 : 0.0,
layer === 0 ? 0.0 : axis === 1 ? 1.0 : 0.0,
layer === 0 ? 0.0 : axis === 2 ? 1.0 : 0.0,
);
splats.pushSplat(center, scales, quaternion, opacity, color);
}
}
}
}
export function constructSpherePoints({
// PackedSplats object to add splats to
splats,
// center of the sphere (default: origin)
origin = new THREE.Vector3(),
// radius of the sphere
radius = 1.0,
// maximum depth of recursion for subdividing the sphere
// Warning: Gsplat count grows exponentially with depth
maxDepth = 3,
// filter function to apply to each point, for example to select
// points in a certain direction or other function ((THREE.Vector3) => boolean)
// (default: null)
filter = null,
// radius of each oriented Gsplat
pointRadius = 0.02,
// flatness of each oriented Gsplat
pointThickness = 0.001,
// color of each Gsplat (THREE.Color) or function to set color for point:
// ((THREE.Color, THREE.Vector3) => void) (default: white)
color = new THREE.Color(1, 1, 1),
}: {
splats: PackedSplats;
origin?: THREE.Vector3;
radius?: number;
maxDepth?: number;
filter?: ((point: THREE.Vector3) => boolean) | null;
pointRadius?: number;
pointThickness?: number;
color?: THREE.Color | ((color: THREE.Color, point: THREE.Vector3) => void);
}) {
const pointsHash: { [key: string]: THREE.Vector3 } = {};
function addPoint(p: THREE.Vector3) {
if (filter && !filter(p)) {
return;
}
const key = `${p.x},${p.y},${p.z}`;
if (!pointsHash[key]) {
pointsHash[key] = p;
}
}
function recurse(
depth: number,
p0: THREE.Vector3,
p1: THREE.Vector3,
p2: THREE.Vector3,
) {
addPoint(p0);
addPoint(p1);
addPoint(p2);
if (depth >= maxDepth) {
return;
}
const p01 = new THREE.Vector3().addVectors(p0, p1).normalize();
const p12 = new THREE.Vector3().addVectors(p1, p2).normalize();
const p20 = new THREE.Vector3().addVectors(p2, p0).normalize();
recurse(depth + 1, p0, p01, p20);
recurse(depth + 1, p01, p1, p12);
recurse(depth + 1, p20, p12, p2);
recurse(depth + 1, p01, p12, p20);
}
for (const x of [-1, 1]) {
for (const y of [-1, 1]) {
for (const z of [-1, 1]) {
const p0 = new THREE.Vector3(x, 0, 0);
const p1 = new THREE.Vector3(0, y, 0);
const p2 = new THREE.Vector3(0, 0, z);
recurse(0, p0, p1, p2);
}
}
}
const points = Object.values(pointsHash);
const scales = new THREE.Vector3(pointRadius, pointRadius, pointThickness);
const quaternion = new THREE.Quaternion();
const pointColor = typeof color === "function" ? new THREE.Color() : color;
for (const point of points) {
quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, -1), point);
if (typeof color === "function") {
color(pointColor, point);
}
point.multiplyScalar(radius);
point.add(origin);
splats.pushSplat(point, scales, quaternion, 1.0, pointColor);
}
}
export function textSplats({
// text string to display
text,
// browser font to render text with (default: "Arial")
font,
// font size in pixels/Gsplats (default: 32)
fontSize,
// SplatMesh.recolor tint assuming white Gsplats (default: white)
color,
// Individual Gsplat color (default: white)
rgb,
// Gsplat radius (default: 0.8 covers 1-unit spacing well)
dotRadius,
// text alignment: "left", "center", "right", "start", "end" (default: "start")
textAlign,
// line spacing multiplier, lines delimited by "\n" (default: 1.0)
lineHeight,
// Coordinate scale in object-space (default: 1.0)
objectScale,
}: {
text: string;
font?: string;
fontSize?: number;
color?: THREE.Color;
rgb?: THREE.Color;
dotRadius?: number;
textAlign?: "left" | "center" | "right" | "start" | "end";
lineHeight?: number;
objectScale?: number;
}) {
font = font ?? "Arial";
fontSize = fontSize ?? 32;
color = color ?? new THREE.Color(1, 1, 1);
dotRadius = dotRadius ?? 0.8;
textAlign = textAlign ?? "start";
lineHeight = lineHeight ?? 1;
objectScale = objectScale ?? 1;
const lines = text.split("\n");
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to create canvas context");
}
ctx.font = `${fontSize}px ${font}`;
ctx.textAlign = textAlign;
const metrics = ctx.measureText("");
const fontHeight =
metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent;
let minLeft = Number.POSITIVE_INFINITY;
let maxRight = Number.NEGATIVE_INFINITY;
let minTop = Number.POSITIVE_INFINITY;
let maxBottom = Number.NEGATIVE_INFINITY;
for (let line = 0; line < lines.length; ++line) {
const metrics = ctx.measureText(lines[line]);
const y = fontHeight * lineHeight * line;
minLeft = Math.min(minLeft, -metrics.actualBoundingBoxLeft);
maxRight = Math.max(maxRight, metrics.actualBoundingBoxRight);
minTop = Math.min(minTop, y - metrics.actualBoundingBoxAscent);
maxBottom = Math.max(maxBottom, y + metrics.actualBoundingBoxDescent);
}
const originLeft = Math.floor(minLeft);
const originTop = Math.floor(minTop);
const width = Math.ceil(maxRight) - originLeft;
const height = Math.ceil(maxBottom) - originTop;
canvas.width = width;
canvas.height = height;
ctx.font = `${fontSize}px ${font}`;
ctx.textAlign = textAlign;
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#FFFFFF";
for (let i = 0; i < lines.length; ++i) {
const y = fontHeight * lineHeight * i - originTop;
ctx.fillText(lines[i], -originLeft, y);
}
const imageData = ctx.getImageData(0, 0, width, height);
const rgba = new Uint8Array(imageData.data.buffer);
const splats = new PackedSplats();
const center = new THREE.Vector3();
const scales = new THREE.Vector3().setScalar(dotRadius * objectScale);
const quaternion = new THREE.Quaternion(0, 0, 0, 1);
rgb = rgb ?? new THREE.Color(1, 1, 1);
let offset = 0;
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
const a = rgba[offset + 3];
if (a > 0) {
const opacity = a / 255;
center.set(x - 0.5 * (width - 1), 0.5 * (height - 1) - y, 0);
center.multiplyScalar(objectScale);
splats.pushSplat(center, scales, quaternion, opacity, rgb);
}
offset += 4;
}
}
const mesh = new SplatMesh({ packedSplats: splats });
mesh.recolor = color;
return mesh;
}
export function imageSplats({
// URL of the image to convert to splats (example: `url: "./image.png"`)
url,
// Radius of each Gsplat, default covers 1-unit spacing well (default: 0.8)
dotRadius,
// Subsampling factor for the image. Higher values reduce resolution,
// for example 2 will halve the width and height by averaging (default: 1)
subXY,
// Optional callback function to modify each Gsplat before it's added.
// Return null to skip adding the Gsplat, or a number to set the opacity
// and add the Gsplat with parameter values in the objects center, rgba etc. were
// passed into the forEachSplat callback. Ending the callback in `return opacity;`
// will retain the original opacity.
// ((width: number, height: number, index: number, center: THREE.Vector3, scales: THREE.Vector3, quaternion: THREE.Quaternion, opacity: number, color: THREE.Color) => number | null)
forEachSplat,
}: {
url: string;
dotRadius?: number;
subXY?: number;
forEachSplat?: (
width: number,
height: number,
index: number,
center: THREE.Vector3,
scales: THREE.Vector3,
quaternion: THREE.Quaternion,
opacity: number,
color: THREE.Color,
) => number | null;
}): SplatMesh {
dotRadius = dotRadius ?? 0.8;
subXY = Math.max(1, Math.floor(subXY ?? 1));
return new SplatMesh({
constructSplats: async (splats) => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onerror = reject;
img.onload = () => {
const { width, height } = img;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Failed to create canvas context"));
return;
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
const destWidth = Math.round(width / subXY);
const destHeight = Math.round(height / subXY);
ctx.drawImage(img, 0, 0, destWidth, destHeight);
try {
const imageData = ctx.getImageData(0, 0, destWidth, destHeight);
const rgba = new Uint8Array(imageData.data.buffer);
const center = new THREE.Vector3();
const scales = new THREE.Vector3().setScalar(dotRadius);
const quaternion = new THREE.Quaternion(0, 0, 0, 1);
const rgb = new THREE.Color();
let index = 0;
for (let y = 0; y < destHeight; ++y) {
for (let x = 0; x < destWidth; ++x) {
const offset = index * 4;
const a = rgba[offset + 3];
if (a > 0) {
let opacity = a / 255;
rgb.set(
rgba[offset + 0] / 255,
rgba[offset + 1] / 255,
rgba[offset + 2] / 255,
);
center.set(
x - 0.5 * (destWidth - 1),
0.5 * (destHeight - 1) - y,
0,
);
scales.setScalar(dotRadius);
quaternion.set(0, 0, 0, 1);
let push = true;
if (forEachSplat) {
const maybeOpacity = forEachSplat(
destWidth,
destHeight,
index,
center,
scales,
quaternion,
opacity,
rgb,
);
opacity = maybeOpacity ?? opacity;
push = maybeOpacity !== null;
}
if (push) {
splats.pushSplat(center, scales, quaternion, opacity, rgb);
}
}
index += 1;
}
}
resolve();
} catch (error) {
reject(error);
}
};
img.src = url;
});
},
});
}