mirror of
https://github.com/storytold/spark.git
synced 2026-10-12 23:49:01 +00:00
420 lines
14 KiB
TypeScript
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;
|
|
});
|
|
},
|
|
});
|
|
}
|