mirror of
https://github.com/storytold/spark.git
synced 2026-10-17 00:12:59 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
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,
|
||||
}: {
|
||||
text: string;
|
||||
font?: string;
|
||||
fontSize?: number;
|
||||
color?: THREE.Color;
|
||||
rgb?: THREE.Color;
|
||||
dotRadius?: number;
|
||||
textAlign?: "left" | "center" | "right" | "start" | "end";
|
||||
lineHeight?: 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;
|
||||
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);
|
||||
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);
|
||||
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.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;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user