Compare commits

...
189 changed files with 37853 additions and 386 deletions
+4
View File
@@ -14,6 +14,10 @@
gtag('config', 'G-Y4Y8KMLPQ7');
</script>
<!-- This can't find three.js
<script src="https://sparkjs.dev/releases/spark/0.1.10/spark.module.js" />
-->
<!-- Microsoft Clarity -->
<script type="text/javascript">
(function(c,l,a,r,i,t,y){
@@ -2,6 +2,8 @@ import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
import { SplatFileType, SplatMesh } from "@sparkjsdev/spark";
import { SPLAT_FILE_TYPE } from "~/enums";
interface LoaderInterface {
file: File;
@@ -32,10 +34,13 @@ export const loadPreviewOnCanvas = ({
const camera = new THREE.PerspectiveCamera(35, aspectRatio, 0.1, 1000);
camera.position.z = 2;
const gl2ctx = canvas.getContext("webgl2");
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvas,
context: gl2ctx!,
preserveDrawingBuffer: true,
});
@@ -52,18 +57,30 @@ export const loadPreviewOnCanvas = ({
scene.add(light);
// load the file into the preview mini-scene depending of the file's type
if (file.name.includes(".glb")) {
if (file.name.endsWith(".glb")) {
glbLoader({ file, scene, camera, renderer, statusCallback });
} else if (file.name.includes(".pmd")) {
} else if (file.name.endsWith(".pmd")) {
pmdLoader({ file, scene, camera, renderer, statusCallback });
} else if (
file.name.includes(".png") ||
file.name.includes(".jpg") ||
file.name.includes(".jpeg") ||
file.name.includes(".gif")
file.name.endsWith(".png") ||
file.name.endsWith(".jpg") ||
file.name.endsWith(".jpeg") ||
file.name.endsWith(".gif")
) {
imagePlaneLoader({ file, scene, camera, renderer, statusCallback });
} else if (file.name.includes(".vmd")) {
} else if (file.name.endsWith(".spz")) {
file.arrayBuffer().then((arrayBuffer) => {
const splatMesh = new SplatMesh({
fileBytes: arrayBuffer, fileType: SplatFileType.SPZ,
onLoad: () => { scene.add(splatMesh) }
});
}).catch((loaderError) => {
statusCallback({
type: "SPLAT Loader Error",
message: String(loaderError),
});
});
} else if (file.name.endsWith(".vmd")) {
statusCallback({
type: "Preview Error",
message: "Sorry, Preview is not available to VMD files yet",
@@ -0,0 +1,188 @@
import { useCallback, useEffect, useState, useRef } from "react";
import { ListDropdown } from "@storyteller/ui-list-dropdown";
import { Button } from "@storyteller/ui-button";
import { FileUploader } from "@storyteller/ui-file-uploader";
import { UploaderState } from "../../../models";
import { FilterEngineCategories, MediaFileAnimationType, UploaderStates } from "../../../enums";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCube } from "@fortawesome/pro-solid-svg-icons";
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "../UploadModal3D/utilities";
interface Props {
title: string;
fileTypes: string[];
engineCategory: FilterEngineCategories;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
hasThumbnailUpload?: boolean;
};
onClose: () => void;
onUploadProgress: (newState: UploaderState) => void;
onLocalBytes: (bytes: ArrayBuffer) => void;
}
export const UploadFilesSplat = ({
fileTypes,
engineCategory,
options,
onClose,
onUploadProgress,
onLocalBytes,
}: Props) => {
const canvasRef = useRef<HTMLCanvasElement | undefined>(undefined);
const canvasCallbackRef = useCallback((node: HTMLCanvasElement) => {
if (node !== null) {
canvasRef.current = node;
}
}, []);
const fileSubtypes = options?.fileSubtypes;
// const hasLength = options?.hasLength;
// const hasThumbnailUpload = options?.hasThumbnailUpload;
const [subtype, setSubtype] = useState<MediaFileAnimationType | undefined>(
fileSubtypes
? (Object.values(fileSubtypes[0])[0] as MediaFileAnimationType)
: undefined,
);
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
const [previewStatus, setPreviewStatus] = useState<{
type: string;
message?: string;
}>({ type: "init" });
const [thumbnailFile, setThumbnailFile] = useState<Blob | undefined>(
undefined,
);
const handleSubmit = () => {
if (!assetFile.value) {
setAssetFile((curr) => ({
...curr,
error: "Please select a file to upload.",
}));
return;
}
// Use the filename as the title
const title = assetFile.value.name.split(".")[0];
assetFile.value.arrayBuffer().then((arrayBuffer) => {
onLocalBytes(arrayBuffer);
}).catch((error) => {
console.error("Error reading file as ArrayBuffer:", error);
});
// upload3DObjects({
// title: title,
// assetFile: assetFile.value,
// thumbnailSnapshot: thumbnailFile,
// engineCategory: engineCategory,
// animationType: subtype,
// progressCallback: onUploadProgress,
// });
};
useEffect(() => {
// Reset subtype when fileSubtypes set changes (e.g., toggling character)
if (!fileSubtypes || fileSubtypes.length === 0) {
setSubtype(undefined);
return;
}
const nextDefault = Object.values(fileSubtypes[0])[0] as
| MediaFileAnimationType
| undefined;
setSubtype(nextDefault);
}, [fileSubtypes]);
useEffect(() => {
if (canvasRef.current && assetFile.value) {
loadPreviewOnCanvas({
file: assetFile.value,
canvas: canvasRef.current,
statusCallback: (statusObject: { type: string; message?: string }) => {
setPreviewStatus(statusObject);
},
});
}
}, [assetFile.value]);
useEffect(() => {
if (previewStatus.type === "OK" && canvasRef.current) {
snapshotCanvasAsThumbnail({
targetNode: canvasRef.current!,
resultCallback: (snapshotBlob) => {
if (snapshotBlob) {
setThumbnailFile(snapshotBlob);
}
},
});
}
}, [previewStatus]);
return (
<>
<div className="flex flex-col gap-3">
<FileUploader
fileTypes={fileTypes}
file={assetFile.value ?? undefined}
handleChange={(file: File) => {
setAssetFile({
value: file,
});
}}
/>
{fileSubtypes && fileSubtypes.length > 1 && (
<ListDropdown
list={fileSubtypes}
onSelect={(value) => setSubtype(value as MediaFileAnimationType)}
/>
)}
{assetFile.error && (
<h6 className="z-10 text-red">{assetFile.error}</h6>
)}
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
<canvas
className="pointer-events-none h-full w-full"
ref={canvasCallbackRef}
/>
{!assetFile.value && (
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
<FontAwesomeIcon icon={faCube} />
Your model preview will appear here
</h6>
)}
{previewStatus.type.includes("Error") && (
<>
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
{previewStatus.type}
{previewStatus.message && <br />}
{previewStatus.message}
</h6>
</>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={!assetFile.value}
>
Upload
</Button>
</div>
</div>
</>
);
};
@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from "react";
import { LoadingDots } from "@storyteller/ui-loading";
import { Modal } from "@storyteller/ui-modal";
import { UploadAssetError, UploadSuccess } from "@storyteller/ui-upload-modal";
import { initialUploaderState, UploaderState } from "../../../models";
import {
FilterEngineCategories,
UploaderStates,
MediaFileAnimationType,
SPLAT_FILE_TYPE,
} from "../../../enums";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
galleryModalVisibleViewMode,
galleryModalVisibleDuringDrag,
} from "@storyteller/ui-gallery-modal";
import { UploadFilesSplat } from "./UploadFilesSplat";
interface Props {
onClose: () => void;
onSuccess: (splatArrayBuffer: ArrayBuffer) => void;
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
hasThumbnailUpload?: boolean;
};
}
const splatFileTypes = Object.values(SPLAT_FILE_TYPE);
export function UploadModalSplat(props: Props) {
const selectedCategory = FilterEngineCategories.SPLAT;
const { isOpen, onClose, onSuccess, title, titleIcon, options } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
const updateUploaderState = (newState: UploaderState) => {
setUploaderState(newState);
};
const resetModalState = () => {
setUploaderState(initialUploaderState);
};
useEffect(() => {
if (isOpen) {
resetModalState();
}
}, [isOpen]);
useEffect(() => {
if (uploaderState.status === UploaderStates.success) {
// Automatically open the global Gallery modal after a successful upload
// galleryModalVisibleViewMode.value = true;
// galleryModalVisibleDuringDrag.value = true;
// onSuccess(selectedCategory);
onClose();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploaderState.status]);
const UploaderModalContent = () => {
switch (uploaderState.status) {
case UploaderStates.ready:
return (
<div className="space-y-4">
<UploadFilesSplat
title={title}
engineCategory={selectedCategory}
fileTypes={splatFileTypes}
options={{
...(options ?? {}),
}}
onClose={onClose}
onUploadProgress={updateUploaderState}
onLocalBytes={(buffer) => { onSuccess(buffer); }}
/>
</div>
);
case UploaderStates.uploadingAsset:
case UploaderStates.uploadingCover:
case UploaderStates.settingCover:
return (
<>
<LoadingDots className="mb-1 bg-transparent" />
<div className="w-100 text-center opacity-50">Uploading...</div>
</>
);
case UploaderStates.success: {
return (
<UploadSuccess
title="Splat"
onOk={() => {
onClose();
// onSuccess(selectedCategory);
}}
/>
);
}
case UploaderStates.assetError:
return (
<UploadAssetError
onCancel={onClose}
onRetry={() => {
resetModalState();
}}
type={selectedCategory}
errorMessage={uploaderState.errorMessage}
/>
);
case UploaderStates.coverCreateError:
case UploaderStates.coverSetError:
return (
<UploadAssetError
onCancel={onClose}
onRetry={() => {
resetModalState();
}}
type={"Thumbnail"}
errorMessage={uploaderState.errorMessage}
/>
);
}
return undefined;
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
titleIcon={titleIcon}
title={title}
className="max-w-xl"
showClose={true}
>
<UploaderModalContent />
</Modal>
);
}
@@ -0,0 +1 @@
export * from "./UploadModalSplat";
@@ -1,2 +1,3 @@
export * from "./UploadModal3D";
export * from "./UploadModalMedia";
export * from "./UploadModalSplat";
@@ -33,9 +33,9 @@ import { CloseButton } from "@storyteller/ui-close-button";
import { ActionReminderModal } from "@storyteller/ui-action-reminder-modal";
import { TaskMediaFileClass } from "@storyteller/api-enums";
import {
getThumbnailUrl,
//getThumbnailUrl,
THUMBNAIL_SIZES,
getPlaceholderForMediaClass,
//getPlaceholderForMediaClass,
} from "@storyteller/common";
type InProgressTask = {
@@ -137,9 +137,9 @@ const CompletedCard = ({
src={task.thumbnailUrl}
alt={task.title}
onError={(e) => {
e.currentTarget.src = getPlaceholderForMediaClass(
task.mediaFileClass,
);
//e.currentTarget.src = getPlaceholderForMediaClass(
// task.mediaFileClass,
//);
e.currentTarget.style.opacity = "0.3";
}}
className="h-full w-full object-cover"
@@ -291,11 +291,12 @@ export const TaskQueue = () => {
id: t.id,
...formatTitleParts(t),
thumbnailUrl:
getThumbnailUrl(
t.completed_item?.primary_media_file
?.maybe_thumbnail_url_template,
{ width: THUMBNAIL_SIZES.MEDIUM },
) || undefined,
//getThumbnailUrl(
// t.completed_item?.primary_media_file
// ?.maybe_thumbnail_url_template,
// { width: THUMBNAIL_SIZES.MEDIUM },
//) || undefined,
undefined,
imageUrls: t.completed_item?.primary_media_file?.cdn_url
? [t.completed_item?.primary_media_file?.cdn_url]
: [],
@@ -42,6 +42,10 @@ export enum OBJECT_FILE_TYPE {
GLB = "glb",
}
export enum SPLAT_FILE_TYPE {
SPZ = "spz",
}
export enum CHARACTER_MMD_FILE_TYPE {
PMD = "pmd",
PMX = "pmx",
@@ -23,6 +23,7 @@ export enum FilterEngineCategories {
IMAGE_PLANE = "image_plane",
LOCATION = "location",
OBJECT = "object",
SPLAT = "splat",
SCENE = "scene",
SET_DRESSING = "set_dressing",
SKYBOX = "skybox",
@@ -3,7 +3,8 @@ import { Retarget } from "../retargeting";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { get_media_url } from "~/Classes/ApiHelpers";
import { MMDLoader } from "three/examples/jsm/loaders/MMDLoader.js";
// Three.js dropped support for MMD
//import { MMDLoader } from "three/examples/jsm/loaders/MMDLoader.js";
import { ClipUI } from "../../clips/clip_ui";
import Ijson from "~/interfaces/Ijson";
@@ -243,11 +244,12 @@ export class CharacterAnimationEngine implements Ijson {
resolve(animationClip_1);
});
} else if (url.includes(".vmd")) {
const mmdLoader = new MMDLoader();
mmdLoader.loadAnimation(url, character as THREE.SkinnedMesh, (mmd) => {
mmd.name = url;
resolve(mmd as THREE.AnimationClip);
});
// Three.js dropped support for MMD
//const mmdLoader = new MMDLoader();
//mmdLoader.loadAnimation(url, character as THREE.SkinnedMesh, (mmd) => {
// mmd.name = url;
// resolve(mmd as THREE.AnimationClip);
//});
}
});
}
@@ -1,11 +1,12 @@
import * as THREE from "three";
import { GLTFLoader, GLTF } from "three/addons/loaders/GLTFLoader.js";
import { MMDLoader } from "three/addons/loaders/MMDLoader.js";
//import { MMDLoader } from "three/addons/loaders/MMDLoader.js";
import { Font } from "three/examples/jsm/loaders/FontLoader.js";
import { generateUUID } from "three/src/math/MathUtils.js";
import { LoadingPlaceHolderManager } from "./placeholder_manager";
import { MMDAnimationHelper, Water } from "three/examples/jsm/Addons.js";
//import { MMDAnimationHelper, Water } from "three/examples/jsm/Addons.js";
import { Water } from "three/examples/jsm/Addons.js";
import { MediaFileType } from "../enums";
import { ChromaKeyMaterial } from "./chromakey";
import { TimeLine } from "./timeline";
@@ -44,7 +45,8 @@ class Scene {
mediaFilesApi: MediaFilesApi;
updateSurfaceIdAttributeToMesh: Function;
helper: MMDAnimationHelper;
//helper: MMDAnimationHelper;
helper: undefined | null;
ambientLight: THREE.AmbientLight | undefined;
hemisphereLight: THREE.HemisphereLight | undefined;
directional_light: THREE.DirectionalLight | undefined;
@@ -91,7 +93,8 @@ class Scene {
this._create_skybox();
// this._create_camera_obj();
this.helper = new MMDAnimationHelper({ afterglow: 0.0 });
this.helper = null;
//this.helper = new MMDAnimationHelper({ afterglow: 0.0 });
this.scene.userData["helper"] = this.helper;
// Subscribe to grid visibility changes
@@ -861,24 +864,24 @@ class Scene {
await this.delay_mmd(500);
return new Promise((resolve, reject) => {
const mmdLoader = new MMDLoader();
mmdLoader.loadWithAnimation(
media_url,
["/resources/pose/Lumine Idle cycle.vmd"],
(mmd) => {
this.helper.add(mmd.mesh, {
animation: mmd.animation,
physics: false,
});
//const mmdLoader = new MMDLoader();
//mmdLoader.loadWithAnimation(
// media_url,
// ["/resources/pose/Lumine Idle cycle.vmd"],
// (mmd) => {
// this.helper.add(mmd.mesh, {
// animation: mmd.animation,
// physics: false,
// });
mmd.mesh.scale.set(0.1, 0.1, 0.1);
resolve(mmd.mesh);
},
progress,
(error) => {
reject(error);
},
);
// mmd.mesh.scale.set(0.1, 0.1, 0.1);
// resolve(mmd.mesh);
// },
// progress,
// (error) => {
// reject(error);
// },
//);
});
}
@@ -24,6 +24,7 @@ import { Vector3 } from "three";
import { filmLength, outlinerState } from "../signals";
import { CharacterAnimationEngine } from "./Engines/CharacterAnimationEngine";
import { SplatMesh } from "@sparkjsdev/spark";
export class TimeLine {
editorEngine: Editor;
@@ -399,6 +400,13 @@ export class TimeLine {
return obj;
}
// This method is for local dev testing of objects
// and bypasses the queue system, that's kinda redundant anyway
public addLocalSplat(fileBytes: ArrayBuffer | Uint8Array) {
const splatMesh = new SplatMesh({ fileBytes: fileBytes });
this.editorEngine.sceneManager?.scene.scene.add(splatMesh);
}
public async addShape(data: MediaItem) {
const pos = this.getPos();
const parim = await this.editorEngine.create_parim(data.media_id, pos);
@@ -2,10 +2,10 @@ import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import {
FBXLoader,
MMDAnimationHelper,
MMDAnimationHelperMixer,
//MMDAnimationHelper,
//MMDAnimationHelperMixer,
} from "three/examples/jsm/Addons.js";
import { MMDLoader } from "three/addons/loaders/MMDLoader.js";
//import { MMDLoader } from "three/addons/loaders/MMDLoader.js";
import { MoveAIResult, Retarget } from "../Editor/retargeting";
import IAnimationClip from "./ClipInterfaces/IAnimationClip";
@@ -32,8 +32,10 @@ export class AnimationClip implements IAnimationClip {
private isMMD: boolean = false;
private obj: MMDAnimationHelperMixer | undefined;
private helper: MMDAnimationHelper | undefined;
//private obj: MMDAnimationHelperMixer | undefined;
//private helper: MMDAnimationHelper | undefined;
private obj: null | undefined;
private helper: null | undefined;
private mmd_anim_clip: THREE.AnimationClip | undefined;
private pose: Object | undefined;
private processingClip: boolean = false;
@@ -119,19 +121,19 @@ export class AnimationClip implements IAnimationClip {
resolve(animationClip);
});
} else if (url.includes(".vmd")) {
console.log("VMD Loader");
const root = this.mixer?.getRoot();
this.isMMD = true;
if (root) {
const mmdLoader = new MMDLoader();
this.mmd_url = url;
mmdLoader.loadAnimation(url, root as THREE.SkinnedMesh, (mmd) => {
mmd.name = this.mmd_url;
resolve(mmd as THREE.AnimationClip);
});
console.log("Loaded");
}
console.log("Dont");
//console.log("VMD Loader");
//const root = this.mixer?.getRoot();
//this.isMMD = true;
//if (root) {
// const mmdLoader = new MMDLoader();
// this.mmd_url = url;
// mmdLoader.loadAnimation(url, root as THREE.SkinnedMesh, (mmd) => {
// mmd.name = this.mmd_url;
// resolve(mmd as THREE.AnimationClip);
// });
// console.log("Loaded");
//}
//console.log("Dont");
}
});
});
@@ -231,12 +233,12 @@ export class AnimationClip implements IAnimationClip {
}
private async load_vpd_wrapped(): Promise<object> {
return new Promise((resolve) => {
const poseLoader = new MMDLoader();
poseLoader.loadVPD("/resources/pose/7.vpd", false, (vpd) => {
resolve(vpd);
}); // End of loadVPD call
}); // End of new Promise
//return new Promise((resolve) => {
// const poseLoader = new MMDLoader();
// poseLoader.loadVPD("/resources/pose/7.vpd", false, (vpd) => {
// resolve(vpd);
// }); // End of loadVPD call
//}); // End of new Promise
}
async animate(deltatime: number) {
@@ -246,72 +248,72 @@ export class AnimationClip implements IAnimationClip {
this.update_bones();
// Next
if (this.isMMD && this.mixer) {
if (this.obj === undefined) {
// Sets the ik helper and object for IK and animation.
this.helper = (this.mixer.getRoot() as THREE.Object3D).parent?.userData[
"helper"
];
}
//if (this.isMMD && this.mixer) {
// if (this.obj === undefined) {
// // Sets the ik helper and object for IK and animation.
// this.helper = (this.mixer.getRoot() as THREE.Object3D).parent?.userData[
// "helper"
// ];
// }
this.obj = this.helper?.objects.get(
this.mixer?.getRoot() as THREE.SkinnedMesh,
);
// this.obj = this.helper?.objects.get(
// this.mixer?.getRoot() as THREE.SkinnedMesh,
// );
const mesh = this.mixer?.getRoot() as THREE.SkinnedMesh;
// const mesh = this.mixer?.getRoot() as THREE.SkinnedMesh;
if (this.helper) {
this.helper._restoreBones(mesh); // Privateish js function for resetting bones.
this.obj?.mixer?.setTime(deltatime); // Sets the time.
this.helper._saveBones(mesh); // Saves the bones location for restoration later.
// if (this.helper) {
// this.helper._restoreBones(mesh); // Privateish js function for resetting bones.
// this.obj?.mixer?.setTime(deltatime); // Sets the time.
// this.helper._saveBones(mesh); // Saves the bones location for restoration later.
mesh.updateMatrixWorld(true); // Updates mesh.
this.obj?.ikSolver.update(); // Updates IK Solver.
this.obj?.grantSolver.update(); // Updates Grant which moves the bones.
}
// mesh.updateMatrixWorld(true); // Updates mesh.
// this.obj?.ikSolver.update(); // Updates IK Solver.
// this.obj?.grantSolver.update(); // Updates Grant which moves the bones.
// }
// Changing of animation.
if (
this.obj &&
this.helper &&
this.processingClip === false &&
this.obj.mixer &&
this.mixer._actions[0]._clip.name !==
this.obj.mixer._actions[0]._clip.name &&
this.mixer !== undefined
) {
// On animation change.
this.processingClip = true;
// // Changing of animation.
// if (
// this.obj &&
// this.helper &&
// this.processingClip === false &&
// this.obj.mixer &&
// this.mixer._actions[0]._clip.name !==
// this.obj.mixer._actions[0]._clip.name &&
// this.mixer !== undefined
// ) {
// // On animation change.
// this.processingClip = true;
const helper_mmd = this.helper.objects.get(mesh);
if (helper_mmd?.mixer !== undefined) {
const clips = helper_mmd.mixer._actions;
clips.forEach((clip) => {
this.helper?.objects.delete(clip._clip);
});
}
// const helper_mmd = this.helper.objects.get(mesh);
// if (helper_mmd?.mixer !== undefined) {
// const clips = helper_mmd.mixer._actions;
// clips.forEach((clip) => {
// this.helper?.objects.delete(clip._clip);
// });
// }
this.helper.remove(mesh);
mesh.pose();
// this.helper.remove(mesh);
// mesh.pose();
const loader = new MMDLoader();
loader.loadAnimation(this.mmd_url, mesh, (mmd) => {
mmd.name = this.mmd_url;
this.helper?.add(mesh, {
animation: mmd,
physics: false,
});
this.processingClip = false;
if (helper_mmd?.mixer !== undefined) {
const clips = helper_mmd.mixer._actions;
clips.forEach((clip) => {
this.helper?.objects.delete(clip._clip);
});
}
this.obj.mixer = this.mixer;
});
}
}
// //const loader = new MMDLoader();
// //loader.loadAnimation(this.mmd_url, mesh, (mmd) => {
// // mmd.name = this.mmd_url;
// // this.helper?.add(mesh, {
// // animation: mmd,
// // physics: false,
// // });
// // this.processingClip = false;
// // if (helper_mmd?.mixer !== undefined) {
// // const clips = helper_mmd.mixer._actions;
// // clips.forEach((clip) => {
// // this.helper?.objects.delete(clip._clip);
// // });
// // }
// // this.obj.mixer = this.mixer;
// //});
// }
//}
}
async step(deltatime: number, isPlaying: boolean, frame: number) {
@@ -45,6 +45,7 @@ import {
} from "@storyteller/ui-gallery-modal";
import { twMerge } from "tailwind-merge";
import { UploadModalImage } from "../../../../components/reusable/UploadModalImage";
import { UploadModalSplat } from "~/components/reusable/UploadModalSplat";
export const Controls3D = () => {
useSignals();
@@ -55,6 +56,7 @@ export const Controls3D = () => {
const [upload3DIsShowing, setUpload3DIsShowing] = useState(false);
const [isAddAssetPopoverOpen, setIsAddAssetPopoverOpen] = useState(false);
const [uploadImageIsShowing, setUploadImageIsShowing] = useState(false);
const [uploadSplatIsShowing, setUploadSplatIsShowing] = useState(false);
// Track processed 3D models by their media token to prevent duplicates
const processedModelsRef = useRef<Record<string, boolean>>({});
@@ -68,7 +70,8 @@ export const Controls3D = () => {
!galleryModalVisibleViewMode.value &&
!isAddAssetPopoverOpen &&
!upload3DIsShowing &&
!uploadImageIsShowing;
!uploadImageIsShowing &&
!uploadSplatIsShowing;
setShowEmptySceneTooltip(isSceneEmpty);
};
@@ -89,6 +92,7 @@ export const Controls3D = () => {
isAddAssetPopoverOpen,
upload3DIsShowing,
uploadImageIsShowing,
uploadSplatIsShowing,
]);
const handleModeChange = (value: string) => {
@@ -210,6 +214,9 @@ export const Controls3D = () => {
case "upload-image":
setUploadImageIsShowing(true);
break;
case "upload-splat":
setUploadSplatIsShowing(true);
break;
default:
break;
}
@@ -324,14 +331,24 @@ export const Controls3D = () => {
),
action: "upload-image",
},
{
label: "Upload Splat",
selected: false,
icon: (
<FontAwesomeIcon
icon={faArrowUpFromBracket}
className="h-4 w-4"
/>
),
action: "upload-splat",
},
]}
onPanelAction={handleAddAssetAction}
showIconsInList
buttonClassName={`h-9 w-9 rounded-[10px] text-lg ${
showEmptySceneTooltip
? "bg-primary/90 hover:bg-primary/70"
: "border-transparent bg-primary/90 hover:bg-primary/70"
}`}
buttonClassName={`h-9 w-9 rounded-[10px] text-lg ${showEmptySceneTooltip
? "bg-primary/90 hover:bg-primary/70"
: "border-transparent bg-primary/90 hover:bg-primary/70"
}`}
triggerIcon={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
@@ -389,6 +406,17 @@ export const Controls3D = () => {
title="Upload an Image"
titleIcon={faImages}
/>
<UploadModalSplat
isOpen={uploadSplatIsShowing}
onClose={() => setUploadSplatIsShowing(false)}
onSuccess={(buffer) => {
setUploadSplatIsShowing(false)
editorEngine?.timeline.addLocalSplat(buffer);
}}
title="Upload an spz file"
titleIcon={faCube}
/>
</>
);
};
+2 -2
View File
@@ -16,7 +16,7 @@
"scripts": {
"build": "vite build",
"preview": "vite preview --port 8080",
"dev": "echo 'editor3d' && vite dev",
"dev": "echo 'editor3d' && NODE_OPTIONS=--max_http_header_size=128000 vite dev",
"production-preview": "echo 'editor3d dev with production preview' && vite build --debug --emptyOutDir --logLevel info && vite preview --port 5173",
"staging-build": "vite build",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint playground/* .",
@@ -73,7 +73,7 @@
"react-virtualized": "^9.22.6",
"tailwind-merge": "^2.2.2",
"terser": "^5.30.3",
"three": "^0.162.0",
"three": "^0.178.0",
"uuid": "^9.0.1",
"wavesurfer.js": "^7.7.11",
"webm-writer": "^1.0.0"
+13 -7
View File
@@ -28,6 +28,9 @@
"noEmit": true
},
"references": [
{
"path": "../../vendor/spark"
},
{
"path": "../../libs/components/pagination"
},
@@ -67,14 +70,11 @@
{
"path": "../../libs/components/loading-spinner"
},
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/components/tutorial-modal"
},
{
"path": "../../libs/common"
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/state/subscription"
@@ -103,6 +103,9 @@
{
"path": "../../libs/components/settings-modal"
},
{
"path": "../../libs/common"
},
{
"path": "../../libs/api-enums"
},
@@ -121,9 +124,6 @@
{
"path": "../../libs/components/popover"
},
{
"path": "../../libs/components/tooltip"
},
{
"path": "../../libs/components/gravatar"
},
@@ -148,6 +148,12 @@
{
"path": "../../libs/components/list-dropdown"
},
{
"path": "../../libs/components/tooltip"
},
{
"path": "../../libs/components/tab-selector"
},
{
"path": "../../libs/components/button"
},
+15
View File
@@ -21,5 +21,20 @@ export default defineConfig({
tsconfigPaths(),
],
server: {
proxy: {
'^/@fs/.*vite/deps/.*.wasm$': {
target: 'http://localhost:5173',
rewrite: (path) => {
const replaced = path.replace(
'storyteller-rust/frontend/apps/editor3d/node_modules/.vite/deps/',
'storyteller-rust/frontend/vendor/spark/rust/spark-internal-rs/pkg/'
);
console.log('original path', path);
console.log('rewritten path', replaced);
return replaced;
},
changeOrigin: true,
},
},
}
});
+1851 -239
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -24,6 +24,7 @@
"konva": "^9.3.20",
"lodash": "^4.17.21",
"lucide-react": "^0.512.0",
"node": "^25.2.1",
"pnpm": "^10.8.1",
"posthog-js": "^1.141.0",
"react": "18.3.1",
@@ -36,7 +37,7 @@
"react-router-dom": "6.29.0",
"react-spring": "^10.0.1",
"tailwind-merge": "^2.2.2",
"three": "^0.162.0",
"three": "^0.178.0",
"uuid": "^9.0.1",
"zustand": "^5.0.5"
},
@@ -92,12 +93,14 @@
"vite": "^6.0.0",
"vite-plugin-banner": "^0.8.0",
"vite-plugin-dts": "~4.5.0",
"vite-plugin-glsl": "^1.5.4",
"vite-plugin-static-copy": "^2.3.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.0"
},
"workspaces": [
"apps/**/*",
"libs/**/*"
"libs/**/*",
"vendor/**/*"
]
}
+3 -3
View File
@@ -1,4 +1,4 @@
packages:
- 'apps/*'
- 'libs/*'
- "apps/*"
- "libs/*"
- "vendor/*"
+4 -1
View File
@@ -28,7 +28,10 @@
// Uncomment if 2D ever gets used
// "apps/editor2d/app/src/*",
"apps/editor3d/app/src/*"
]
],
//"@sparkjsdev/spark": [
// "vendor/spark/src/index.ts"
//]
}
}
}
+3
View File
@@ -173,6 +173,9 @@
},
{
"path": "./libs/api-enums"
},
{
"path": "./vendor/spark"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
# FIX CRLF always when developer has not set
# Linux/Mac: git config --global core.autocrlf input
# Windows: git config --global core.autocrlf true
# Auto detect text files and perform LF normalization
* text=auto
* eol=lf
*.html text
*.glsl text
*.js text
*.md text
*.ts text
+52
View File
@@ -0,0 +1,52 @@
{
"extends": [
"../../.eslintrc.json"
],
"ignorePatterns": [
"!**/*",
"**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*"
],
"overrides": [
{
"files": [
"*.ts",
"*.tsx",
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": [
"*.ts",
"*.tsx"
],
"rules": {}
},
{
"files": [
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": [
"*.json"
],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": [
"error",
{
"ignoredFiles": [
"{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}",
"{projectRoot}/vite.config.{js,ts,mjs,mts}"
]
}
]
}
}
]
}
+143
View File
@@ -0,0 +1,143 @@
### 0.1.10 (Oct 24, 2025)
[SOG v2](https://blog.playcanvas.com/playcanvas-open-sources-sog-format-for-gaussian-splatting/) support, new examples and bug fixes
### Enhancements
- SOGSv2 (SOG) compression format support (#179) (@mrxz)
- Use DecompressionStream for decompressing .spz files for faster loading times (#181) (@mrxz)
- Make WASD default control for viewer (#6d7d801) (@dmarcos)
- Add `minPixelRadius` property to discard splats during vertex shader (#184) (@mrxz)
- Docs enhancements (@querielo)
- Interactive holes example (#189) (@kali-shade)
- Interactive ripples effect (#194) (@kali-shade)
- Support logarithmic depth buffer (#199) (@Philipp-M)
### Bug fixes
- Issue preventing the splats from being updated when they should. (#191) (@mrxz)
- Make bash script conform to "standard" (#197) (@Philipp-M)
- Fix issue with splats rendered at incorrect position for a few frames (#200) (fix #192, #193) (@mrxz)
### 0.1.9 (Sep 22, 2025)
Performance improvements, SPZ v3 support, new splat transition and reveal effects, brush painting / erasing splat example.
### Enhancements
- New splat transition effects (#172) (@kali-shade)
- New splat reveal effects (#153, #149) (@kali-shade)
- Add support for SPZ v3 (fix #151) (#171) (@gwegash, @dmarcos)
- Reduce small memory allocations when loading ply files (#147) (@mrxz)
- Improve .ply parsing speed using compiled parser function (#150) (@mrxz)
- Add support for ortographic rendering (#157) (@mrxz)
- Make sure all examples resize when the window is resized (#155) (@mrxz)
- Call gl.flush() to encourage eager execution. SparkRenderer.updateInternal was not immediately executed (#156) (@mrxz)
- Use child mesh in `SplatMesh` to auto-inject `SparkRenderer` instead of monkey-patching (#158) (@mrxz)
- Pre-compute lookup tables when parsing SOGS files (#159) (@mrxz)
- Avoid allocating THREE.Quaternion instances in `setPackedSplat` (#160) (@mrxz)
- Use native `Float16Array` to encode a number as a float16 if available. (#161) (@mrxz)
- Splat brush painting / brush erasing example (#165) (@winnie1994)
### 0.1.8 (July 31, 2025)
Bug fix + SplatMesh bounding box calculation.
### Bug fixes
- Fix SH encoding scale factors (#142) (@asundqui, @mrxz, @heimeii)
- Calculate a SplatMesh's bounding box! `SplatMesh.getBoundingBox()` (#126) (@winnie1994)
### 0.1.7 (July 30, 2025)
Image quality and performance improvements.
### Enhancements
- Customizable splat encoding ranges (rgb, sh1, sh2, sh3) for wider range of colors and scales support improving contrast and color reproduction. Expose `premultipliedAlpha` flag to use when accumulating splat RGB (#134) (@asundqui)
- [Experimental Stochastic splat ordering option](https://sparkjs.dev/examples/stochastic/). Faster rendering since sorting no longer needed but with some visual quality tradeoffs (#8f5596e) (@asundqui)
- Higher precision mode (float32) for splat sorting in addition to the default one (float16). Addresses z-fighting issues between splats (@asundqui, @mrxz) (#129)
- Allow decoding and parsing of SOGS images to happen in parallel (@mrxz) (#122)
- New [splat shaders effect example](http://sparkjs.dev/examples/splat-shader-effects) (#141) (@kali-shade)
- Expose `minAlpha` and `maxPixelRadius` in the [SparkRenderer](https://sparkjs.dev/docs/spark-renderer/) parameters (#130) (@asundqui)
- Tree-shaking on worker code (@mrxz) (#118)
- Add JSDocs to docs (@mrxz) (#123)
- Use THREE.js built-in [full screen quad](https://github.com/mrdoob/three.js/blob/95febf473cc326ac2029c51442b2fea3348c5321/examples/jsm/postprocessing/Pass.js#L138) instead of custom setup to cover the entire render target (@mrxz) (#121)
- Redunce bundle size by removing `anyhow::anyhow` dependency (#127) (@asundqui)
### Deprecations
- Remove `SparkRenderer` blending parameter. Rely instead on `THREE.js` built-in support for `premultipliedAlpha` that sets the right blending mode automatically (#136) (@mrxz)
### 0.1.6 (July 11, 2025)
Visual quality improvements, .zip sogs file support, bug fixes.
### Enhancements
- Can load [SOGS](https://blog.playcanvas.com/playcanvas-adopts-sogs-for-20x-3dgs-compression/) compressed splats packaged in a .zip file (#100) (@asundqui)
- Rename `SparkRenderer` renderScale parameter to [focalAdjustment](https://sparkjs.dev/docs/spark-renderer/#optional-parameters) (#113) (fix #99) (@asundqui, @mrxz)
- Use OffscreenCanvas and WebGL2 context to decode webp SOGS images instead of 3rd party dependency (#90) (@mrxz)
- [Animated transitions between splats example](https://sparkjs.dev/examples/splat-transitions/) (#69) (@winnie1994)
- [Example of loading a SOGS compressed splat](https://sparkjs.dev/examples/sogs/) (@dmarcos, @vincentwoo, @61cygni)
- Expand value range of internal splat encoding to improve visual quality. Lower zero cut-off to retain and render small splats. (#110) (@asundqui, @mrxz)
### Bug fixes
- Fix visible property of SplatMesh not having effect. (fix #77) (#100) (@asundqui, @cyango)
- Add missing sh1 and sh2 support to SOGS compressed support (fix #108) (#109) (@lucasoptml)
- Prevent unintentional reuse of ArrayBuffer on concurrent file requests or hits to THREE.Cache. Replace use of THREE.FileLoader with fetch API (#94, #112) (fix #93) (@mrxz, @asundqui)
### 0.1.5 (July 1, 2025)
Visual quality improvements and [SOGS](https://blog.playcanvas.com/playcanvas-adopts-sogs-for-20x-3dgs-compression/) support
### Enhancements
- Add support for [SOGS](https://blog.playcanvas.com/playcanvas-adopts-sogs-for-20x-3dgs-compression/) compression format 3D Gaussian Splatting (#73) (@asundqui)
- Change splat shapes by using any RGBA texture to compute the Gaussian falloff (#79) (@asundqui)
- Use RenderTarget properties to reduce manual render state tracking (#80) (@mrxz)
### Bug fixes
- Visual quality, Fix .ksplat decoding (fix #66) (@asundqui)
- Visual quality, Fix Spherical Harmonics not included in SPZ transcoding (fix #66) (#83) (@asundqui)
- Visual quality, Fix incorrect calculation of renderer size. Especially improves rendering in high DPI displays (#71) (@mrxz)
- Fix support of compressed .ply files exported from SuperSplat. Newer versions include min/max_r/g/b properties in the header that were not parsed (#82) (@asundqui)
### 0.1.4 (June 24, 2025)
### Enhancements
- Ability to render depth and normal values (#58) (@asundqui)
- New parameters to change renderer focal distance and aperture angle (#59) (@asundqui)
- GLSL code injection example (#56)
- WebXR example (#50)
### Bug fixes
- Option to disable SparkControls camera roll for touch controls (fix #46) (#60) (@asundqui)
- Fix sign in SH2 coefficient signs improving visual quality (#64)
### 0.1.3 (June 11, 2025)
Fix types export in npm package.
### 0.1.2 (June 10, 2025)
It removes unnecessary dependencies from npm package.
### 0.1.1 (June 10, 2025)
### Bug fixes
- Fix compressed .ply files by gsplat not loading (#34) (@bolopenguin, @asundqui)
- Fix image quality rendering with mostly transparent splats (#36) (@hybridherbst, @@asundqui)
- Fix SplatMesh not rendering when it's a child of an Object3D (#38) (@dmarcos)
### 0.1.0 (June 2, 2025)
First release
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright © 2025 WORLD LABS TECHNOLOGIES, INC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+212
View File
@@ -0,0 +1,212 @@
<p align="center">
![Spark logo](https://github.com/user-attachments/assets/5287631a-083c-4c86-80f6-4dca24aa263f#gh-light-mode-only)
![Spark logo](https://github.com/user-attachments/assets/91e8d74b-84a5-4073-bd72-d7228f948dc6#gh-dark-mode-only)
<h3 align="center">An advanced 3D Gaussian Splatting renderer for THREE.js</h3>
<div align="center">
[Features](#features) -
[Getting Started](#getting-started) -
<a href="https://sparkjs.dev/">Documentation</a> -
<a href="https://sparkjs.dev/">FAQ</a>
</div>
</p>
<div align="center">
[![License](https://img.shields.io/badge/license-MIT-%23d43e4c)](https://github.com/sparkjsdev/spark/blob/main/LICENSE)
[![npm version](https://img.shields.io/npm/v/@sparkjsdev/spark?color=d43e4c)](https://www.npmjs.com/package/@sparkjsdev/spark)
</div>
<p>
<a href="https://sparkjs.dev" target="_blank">
<picture>
</picture>
</a>
## Features
- Integrates with THREE.js rendering pipeline to fuse splat and mesh-based objects
- Portable: Works across almost all devices, targeting 98%+ WebGL2 support
- Renders fast even on low-powered mobile devices
- Render multiple splat objects together with correct sorting
- Most major splat file formats supported including: [.PLY](https://github.com/graphdeco-inria/gaussian-splatting) (also [compressed](https://blog.playcanvas.com/compressing-gaussian-splats/#compressed-ply-format)), [.SPZ](https://github.com/nianticlabs/spz), [.SPLAT](https://github.com/antimatter15/splat), [.KSPLAT](https://github.com/mkkellogg/GaussianSplats3D), [.SOG](https://developer.playcanvas.com/user-manual/gaussian-splatting/formats/sog/)
- Render multiple viewpoints simultaneously
- Fully dynamic: each splat can be transformed and edited for animation
- Real-time splat color editing, displacement, and skeletal animation
- Shader graph system to dynamically create/edit splats on the GPU
Check out all the [examples](https://sparkjs.dev/examples/)
## Getting Started
### Copy Code
Copy the following code into an `index.html` file.
```html
<style> body {margin: 0;} </style>
<script type="importmap">
{
"imports": {
"three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/0.178.0/three.module.js",
"@sparkjsdev/spark": "https://sparkjs.dev/releases/spark/0.1.10/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SplatMesh } from "@sparkjsdev/spark";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement)
const splatURL = "https://sparkjs.dev/assets/splats/butterfly.spz";
const butterfly = new SplatMesh({ url: splatURL });
butterfly.quaternion.set(1, 0, 0, 0);
butterfly.position.set(0, 0, -3);
scene.add(butterfly);
renderer.setAnimationLoop(function animate(time) {
renderer.render(scene, camera);
butterfly.rotation.y += 0.01;
});
</script>
```
### Web Editor
Remix the [glitch starter template](https://glitch.com/edit/#!/sparkjs-dev)
### CDN
```html
<script type="importmap">
{
"imports": {
"three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/0.178.0/three.module.js",
"@sparkjsdev/spark": "https://sparkjs.dev/releases/spark/0.1.9/spark.module.js"
}
}
</script>
```
### NPM
```shell
npm install @sparkjsdev/spark
```
## Run Examples locally
Install [Rust](https://www.rust-lang.org/tools/install) if it's not already installed in your machine.
Next, build Spark by running:
```
npm install
npm run build
```
This will first build the Rust Wasm component (can be invoked via `npm run build:wasm`), then Spark itself (`npm run build`).
The examples fetch assets from a remote URL. This step is optional, but offline development and faster loading times are possible if you download and cache the assets files locally with the following command:
```
npm run assets:download
```
Once you've built Spark and optionally downloaded the assets, you can now run the examples:
```
npm start
```
This will run a dev server by default at [http://localhost:8080/](http://localhost:8080/). Check the console log output to see if yours is served on a different port.
## Develop and contribute to the project
### Build troubleshooting
First try cleaning all the build files and re-building everything:
```
npm run clean
npm install
npm run build
```
There's no versioning system for assets. If you need to re-download a specific file you can delete that asset file individually or download all assets from scratch:
```
npm run assets:clean
npm run assets:download
```
### Ignore dist directory during development
To ignore the dist directory and prevent accidental commits and merge conflicts
```
git update-index --assume-unchanged dist/*
```
To revert and be able to commit into to the dist directory again:
```
git update-index --no-assume-unchanged dist/*
```
To list ignored files in case of need to troubleshoot
```
git ls-files -v | grep '^[a-z]' | cut -c3-
```
### Build docs and site
Install [Mkdocs Material](https://squidfunk.github.io/mkdocs-material/)
```
pip install mkdocs-material
```
If you hit an `externally managed environment` error on macOS and if you installed python via `brew` try:
```
brew install mkdocs-material
```
Edit markdown in `/docs` directory
```
npm run docs
```
### Build Spark website
Build the static site and docs in a `site` directory.
```
npm run site:build
```
You can run any static server in the `site` directory but for convenience you can run
```
npm run site:serve
```
### Deploy Spark website
The following command will generate a static site from the `docs` directory and push it to the [repo](https://github.com/sparkjsdev/sparkjsdev.github.io) that hosts the site via `gh-pages`
```
npm run site:deploy
```
### Compress splats
To compress a splat to [spz](https://scaniverse.com/spz) run
`npm run assets:compress <file or URL to ply>`
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": [
"dist/*",
"examples/assets/",
"examples/showcase/hello-gsplat",
"rust/target",
"rust/spark-internal-rs/pkg",
"src/vrButton.ts",
"site",
"site-repo",
"docs"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"lineEnding": "lf"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
dist
examples
viewer
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="530"
height="250"
viewBox="0 0 530 250"
fill="none"
version="1.1"
id="svg278"
sodipodi:docname="logo.svg"
inkscape:version="1.2.1 (9c6d41e, 2022-07-14)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs282" />
<sodipodi:namedview
id="namedview280"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="0.944"
inkscape:cx="265.36017"
inkscape:cy="125"
inkscape:window-width="1309"
inkscape:window-height="456"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="svg278" />
<path
d="M40.3564 215.311C45.3883 215.311 49.1623 214.583 51.6782 213.126C54.1942 211.67 55.4521 209.286 55.4521 205.976C55.4521 203.725 54.8563 201.937 53.6645 200.613C52.4727 199.289 50.354 197.964 47.3084 196.64L26.2539 186.907C18.4412 183.332 12.4161 178.565 8.17872 172.606C3.94132 166.647 1.82263 157.974 1.82263 146.586C1.82263 132.02 5.59656 121.162 13.1444 114.011C20.8247 106.728 33.4707 103.02 51.0823 102.888C58.3653 102.888 64.9863 103.219 70.9451 103.881C76.904 104.543 81.671 105.271 85.2463 106.066C88.8216 106.728 90.6093 107.059 90.6093 107.059L86.6367 139.038C86.6367 139.038 84.9815 138.84 81.671 138.442C78.493 138.045 74.5204 137.714 69.7533 137.449C64.9863 137.052 60.3516 136.853 55.8494 136.853C51.0823 136.853 47.4408 137.582 44.9249 139.038C42.5413 140.362 41.3496 142.944 41.3496 146.785C41.3496 148.903 42.2765 150.691 44.1304 152.148C45.9842 153.472 48.5664 154.862 51.8768 156.319L68.7602 163.668C77.7647 167.641 84.3194 172.606 88.4244 178.565C92.5293 184.392 94.5818 192.734 94.5818 203.592C94.5818 218.291 90.7417 229.612 83.0614 237.558C75.3811 245.503 62.7352 249.541 45.1235 249.674C36.3839 249.674 28.2401 249.078 20.6923 247.886C13.1444 246.827 6.45728 245.569 0.630859 244.112L4.60342 212.133C4.60342 212.133 6.19244 212.398 9.37048 212.928C12.6809 213.457 17.0508 213.987 22.4799 214.517C27.9091 215.046 33.8679 215.311 40.3564 215.311Z"
fill="black"
id="path264"
style="fill:#ffffff" />
<path
d="M105.441 247.688L105.283 104.676H151.721C163.639 104.676 173.504 106.331 181.317 109.641C189.13 112.819 194.956 118.248 198.796 125.929C202.636 133.609 204.556 144.07 204.556 157.312C204.556 175.586 200.187 188.761 191.447 196.839C182.707 204.916 169.466 208.955 151.721 208.955H144.174V247.688H105.441ZM144.174 180.551C150.53 180.551 155.231 178.83 158.276 175.387C161.454 171.944 163.043 165.787 163.043 156.915C163.043 150.161 162.447 145.063 161.256 141.62C160.196 138.177 158.276 135.86 155.495 134.668C152.847 133.344 149.073 132.682 144.174 132.682V180.551Z"
fill="black"
id="path266"
style="fill:#ffffff" />
<path
d="M240.887 228.421L237.113 247.688H195.6L226.418 114.017L183.707 76.5293L265.17 96.9426L244.641 123.649L283.517 121.162L310.804 247.688H269.688L265.914 228.421H240.887ZM244.859 197.633H261.743L253.4 145.593L244.859 197.633Z"
fill="black"
id="path268"
style="fill:#ffffff" />
<path
d="M359.88 104.676C371.533 104.676 381.266 106.198 389.079 109.244C396.891 112.157 402.718 117.123 406.558 124.141C410.53 131.027 412.517 140.495 412.517 152.545C412.517 162.609 410.994 170.885 407.948 177.373C404.903 183.729 400.268 188.828 394.044 192.668L418.277 247.688H375.175L359.483 201.209H353.127V247.688H314.395V125.135L341.069 131.557L318.133 104.676H359.88ZM353.127 174.593C359.218 174.593 363.72 173.07 366.634 170.024C369.547 166.978 371.003 161.616 371.003 153.935C371.003 147.844 370.474 143.342 369.414 140.429C368.355 137.383 366.501 135.33 363.853 134.271C361.337 133.212 357.762 132.682 353.127 132.682V174.593Z"
fill="black"
id="path270"
style="fill:#ffffff" />
<path
d="M428.21 104.676H466.942V162.079L488.195 104.676H529.907L503.291 173.997L528.517 247.688H486.805L466.942 187.106V247.688H428.21V104.676Z"
fill="black"
id="path272"
style="fill:#ffffff" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M309.449 81.6439L327.348 19.5389L375.544 0.839203L348.827 48.8799L309.449 81.6439Z"
fill="black"
id="path274"
style="fill:#ffffff" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M268.575 30.8712L282.27 79.3501L253.733 52.8261L268.575 30.8712Z"
fill="black"
id="path276"
style="fill:#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+16
View File
@@ -0,0 +1,16 @@
# Community Resources
A collection of resources and examples created by the community. To add your own, please submit a PR to edit this page at [sparkjsdev/spark](https://github.com/sparkjsdev/spark/tree/main/docs/docs/community-resources.md).
## Discord
Join the [Spark Discord](https://discord.gg/W39qmSKemS) to connect with other users and developers.
## React Examples
Spark can be used alongside or within React for declarative scene management, dynamic rendering, and state management between your user interface and the 3D scene. See the following examples for how to use Spark with React.
- [`spark-react-basic`](https://github.com/sparkjsdev/spark-react-basic): A basic example of creating a `<canvas>` and THREE.js scene with Spark.
- [`spark-react-r3f`](https://github.com/sparkjsdev/spark-react-r3f): Use Spark declaratively in React with [React Three Fiber](https://r3f.docs.pmnd.rs).
- [`spark-react-router`](https://github.com/sparkjsdev/spark-react-router): An example of using Spark and React Three Fiber with [React Router](https://reactrouter.com) v7 framework mode with SSR.
- [`spark-react-nextjs`](https://github.com/sparkjsdev/spark-react-nextjs): An example of using Spark and React Three Fiber with Next.js App Router.
+152
View File
@@ -0,0 +1,152 @@
# Controls
A program using `Spark` can use any camera control scheme that is compatible with THREE.js and will typically manipulate a `THREE.Camera` object's transform. `Spark` also ships with simple, intuitive controls for navigating 3D space that use the keyboard + mouse, game pad, or mobile multi-touch. To add these controls, you can create a `SparkControls` instance:
```typescript
const controls = new SparkControls({
canvas: HTMLCanvasElement;
});
renderer.setAnimationLoop((time) => {
renderer.render(scene, camera);
controls.update(camera);
});
```
`SparkControls` instantiates two classes `FpsMovement` and `PointerControls` that it updates internally. You can also instantiate and use these two classes separately:
## `class FpsMovement`
`FpsMovement` implements controls that will be familiar to anyone who plays First Person Shooters using keyboard + mouse or a gamepad. Creating a `FpsMovement` instance provides many parameters:
```typescript
const fpsMovement = new FpsMovement({
moveSpeed?: number;
rollSpeed?: number;
stickThreshold?: number;
rotateSpeed?: number;
keycodeMoveMapping?: { [key: string]: THREE.Vector3 };
keycodeRotateMapping?: { [key: string]: THREE.Vector3 };
gamepadMapping?: {
[button: number]: "shift" | "ctrl" | "rollLeft" | "rollRight";
};
capsMultiplier?: number;
shiftMultiplier?: number;
ctrlMultiplier?: number;
xr?: THREE.WebXRManager;
});
```
When gamepads are connected, `FpsMovement` will always use gamepad index 0 for twin-stick movement and rotation.
If `xr` is passed in, the WebXR controllers can be used as a split gamepad to control movement and rotation. (tested on Quest 3)
### Optional parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `moveSpeed` | `1.0` | Base movement speed |
| `rollSpeed` | `2.0` | Speed of roll rotation |
| `stickThreshold` | `0.1` | Deadzone for gamepad analog sticks |
| `rotateSpeed` | `2.0` | Speed of rotation when using gamepad or keys |
| `keycodeMoveMapping` | `{...WASD_KEYCODE_MOVE, ...ARROW_KEYCODE_MOVE}` | Maps keyboard keys to movement directions |
| `keycodeRotateMapping` | `{...QE_KEYCODE_ROTATE, ...ARROW_KEYCODE_ROTATE}` | Maps keyboard keys to rotation directions |
| `gamepadMapping` | `{4: "rollLeft", 5: "rollRight", 6: "ctrl", 7: "shift"}` | Maps gamepad buttons to actions |
| `capsMultiplier` | `10.0` | Speed multiplier when Caps Lock is active |
| `shiftMultiplier` | `5.0` | Speed multiplier when Shift is held |
| `ctrlMultiplier` | `0.2` | Speed multiplier when Ctrl is held |
| `xr` | `undefined` | Optional WebXR manager for XR controller stick support
### `update(deltaTime, control)`
Call this method in your render loop with `control` set to the object to control (`THREE.Camera` or a `THREE.Object3D` that contains it), with `deltaTime` in seconds since the last update.
The update method handles:
- Processing keyboard input for movement and rotation
- Processing gamepad input for movement and rotation
- Applying speed multipliers based on modifier keys and gamepad buttons
- Applying movement and rotation to the controlled object
## `class PointerControls`
`PointerControls` implements pointer/mouse/touch controls on the canvas, for both desktop and mobile web applications. Creating a new control:
```typescript
const pointerControls = new PointerControls({
canvas: HTMLCanvasElement;
rotateSpeed?: number;
slideSpeed?: number;
scrollSpeed?: number;
reverseRotate?: boolean;
reverseSlide?: boolean;
reverseSwipe?: boolean;
reverseScroll?: boolean;
moveInertia?: number;
rotateInertia?: number;
doublePress?: ({
position,
intervalMs,
}: { position: THREE.Vector2; intervalMs: number }) => void;
})
```
### Require parameters
| Parameter | Description |
|-----------|-------------|
| `canvas` | The HTML canvas element to attach pointer events to |
### Optional parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `rotateSpeed` | `0.002` | Speed of rotation when dragging |
| `slideSpeed` | `0.006` | Speed of sliding when dragging with right button or two fingers |
| `scrollSpeed` | `0.0015` | Speed of movement when using mouse wheel |
| `reverseRotate` | `false` | Reverse the direction of rotation |
| `reverseSlide` | `false` | Reverse the direction of sliding |
| `reverseSwipe` | `false` | Reverse the direction of swipe gestures |
| `reverseScroll` | `false` | Reverse the direction of scroll wheel movement |
| `moveInertia` | `0.15` | Inertia factor for movement |
| `rotateInertia` | `0.15` | Inertia factor for rotation |
| `doublePress` | `undefined` | Callback function for double-press/double-tap events |
### `update(deltaTime, control)`
Call this method in your render loop with `control` set to the object to control (`THREE.Camera` or a `THREE.Object3D` that contains it), with `deltaTime` in seconds since the last update.
The update method handles:
- Processing pointer/mouse/touch movements for rotation
- Handling dual-press touch gestures for camera movement
- Applying scroll wheel input
- Calculating and applying inertia for smooth motion
- Updating the camera position and orientation
## Adding a simple GUI to configure controls
Add `lil-gui` to your package (`npm add lil-gui`) to provide a simple configurable GUI.
```typescript
import GUI from "lil-gui";
const gui = new GUI({ title: "Settings + Controls" }).close();
const controlOptions = {
reversePointerFps: false,
reversePointerPan: false,
};
gui
.add(controlOptions, "reversePointerFps")
.name("Reverse Pointer FPS")
.onChange((value: boolean) => {
pointerControls.reverseRotate = value;
pointerControls.reverseScroll = value;
});
gui
.add(controlOptions, "reversePointerPan")
.name("Reverse Pointer Pan")
.onChange((value: boolean) => {
pointerControls.reverseSlide = value;
pointerControls.reverseSwipe = value;
});
```
+148
View File
@@ -0,0 +1,148 @@
# Dyno shaders
The `dyno` shader graph system is one of the architectural pillars of Spark, allowing you to create custom computation graphs using Javascript (and optionally GLSL) that are compiled to GLSL and run on the GPU, similar to shader graph systems in modern 3D graphics engines.
A core component of this system is the class `Dyno` and its subclasses, which can be thought of as function blocks with multiple typed inputs and outputs. Values passed between such blocks are of type `DynoVal<T>`, where `T` must be a `DynoType`, representing a GPU type in GLSL. Using TypeScript, Spark ensures type safety and static validation of the GPU computation graph.
All `dyno` code is contained within `src/dyno`, and has definitions which cover all built-in GLSL ES 3.0 types (`"int"`, `"float"`, `"vec"`, etc.) and most of the standard functions (`mul`, `cross`, `texelFetch`, etc.). Note that regular Javascript functions can't be part of a `dyno` computation graph: instead of `x + y` you should use `dyno.add(x, y)`. You can also define your own custom types and `dyno` blocks, both by compositing existing `dyno` functions or by writing GLSL code directly.
Spark currently uses these Dynos in two main places:
- Dynamically generating splats from `SplatGenerator`/`SplatMesh` into the scene
- Computing the splat distance metric for CPU readback and sorting
At the lowest layer, these are executed as pseudo-compute shaders by the classes `PackedSplats` and `Readback`, respectively. A `PackedSplats` is a collection of splats stored in a cache-efficient 16-byte/splat format, which can be loaded from a splat file URL, or generated via `dyno` program that maps an integer index `DynoVal<"int">` to a `DynoVal<Gsplat>`, a GLSL struct type that contains all the splat parameters. Similarly, `Readback` takes a `dyno` program that maps an index `DynoVal<"int">` to a RGBA8 value via a `DynoVal<"vec4">`, producing a 32-bit value per index that can then be read back to the CPU for splat sorting.
Learning to build and use `dyno` programs is probably best approached by starting with the Particle Simulation example and by examining `SplatMesh.constructGenerator()` for a more complex graph. `SplatMesh` also has two injection points `objectModifier` and `worldModifier` that allow you to inject `dyno` component into the standard splat generation pipeline to modify an existing splat object before it is rendered.
## `type DynoType`
A `DynoType` can be either a string that corresponds to a built-in GLSL type, or `{ type: "MyType" }` for a user-defined type. These types are used both for identifying value types `DynoVal<T extends DynoType>` and for declaring input/output types for `Dyno` blocks, for example `{ index: "int" }` or `{ gsplat: Gsplat }` (`Gsplat` is defined as `{ type: "Gsplat" }`). Spark uses these to enforce TypeScript constraints on inputs+outputs of `Dyno` blocks to generate correct GLSL code.
### Build-in types
A built-in GLSL type can be single-valued (`"int"`, `"uint"`, `"float"`, `"bool"`), vector (`"vec4"`, `"ivec3"`, `"bvec2"`), matrix (`"mat4"`, `"mat2x3"`), or a sampler (`"sampler2D"`, `"usampler3D"`, etc).
### Custom types
Spark defines a handful of custom types that are useful: `Gsplat`, `TPackedSplats`, `SdfArray`, `TRgbaArray`, and `SplatSkinning`. For example, in `src/dyno/splats.ts` we define `Gsplat` as `{ type: "Gsplat" }` along with a helper function:
```typescript
export const defineGsplat = unindent(`
struct Gsplat {
vec3 center;
uint flags;
vec3 scales;
int index;
vec4 quaternion;
vec4 rgba;
};
const uint GSPLAT_FLAG_ACTIVE = 1u << 0u;
bool isGsplatActive(uint flags) {
return (flags & GSPLAT_FLAG_ACTIVE) != 0u;
}
`);
```
When declaring a new user type, you should also define how that type will appear in GLSL, along with any other definitions or utility functions that are useful for that type. When creating a new `Dyno` block you can then include these definitions wherever a `Gsplat` type is used to make sure these global definitions are available.
## `type DynoVal<T extends DynoType>`
A `DynoVal<T extends DynoType>` corresponds to a value of type `T` in the GLSL computation graph. These values can come from a literal/constant value, a uniform (a value that can be updated every frame from your program), or the output of a `Dyno` block.
### `DynoLiteral` / `dynoLiteral`
The class `DynoLiteral<T extends DynoType>` is a `DynoVal` whose constant value is given as a GLSL literal string. For example, `new DynoLiteral("float", "1.0")` or `new DynoLiteral("vec3", "vec3(1.0, 2.0, 3.0)")` produce `DynoVal`s that can be used as inputs in a `dyno` graph.
The helper function `dynoLiteral(type, literal)` is a shorthand for `new DynoLiteral(type, literal)`.
### `DynoConst` / `dynoConst`
Creating a new `DynoConst<T extends DynoType>` is similar to a `DynoLiteral`, but the value is given as a Javascript value, which is converted to a GLSL literal string. For example, `new DynoConst("float", 1.0)` or `new DynoConst("vec3", new THREE.Vector3(1.0, 2.0, 3.0))` produce `DynoVal`s that can be used as inputs in a `dyno` graph.
You can also use the helper function `dynoConst(type, value)` to create a `DynoConst`.
### `DynoOutput`
A `DynoOutput` is created internally in the `dyno` system to represent a particular named output of a `Dyno` block. For example, `dyno.splitGsplat` takes a `DynoVal<Gsplat>` as input and produces multiple outputs including `index`, `center`, `rgba`, etc., and selecting a particular output will yield the appropriate typed `DynoOutput`:
```typescript
// opacity is a DynoOutput and also a DynoVal<"float">
const { opacity } = dyno.splitGsplat(gsplat);
```
## `class Dyno<InTypes, OutTypes>`
A `Dyno` an abstract block that has named, typed inputs and outputs, both as part of its type signature (for TypeScript checking) and stored in the class (for runtime access). When creating a new `Dyno` the following constructor options are available:
| **Option** | **Description** |
|----------|-------------|
| `inTypes` | A map from input name to `DynoType`, for example `{ index: "int" }`. This must match the template parameter `InTypes` of the `Dyno` class. |
| `outTypes` | A map from output name to `DynoType`, for example `{ rgba: "vec4" }`. This must match the template parameter `OutTypes` of the `Dyno` class. |
| `inputs` | A mapping of input values that are passed to the `Dyno`, where the type of `DynoVal<T>` must match the `DynoType` in `inTypes`. |
| `update` | Optional function that is called (no return value) each time a program containing this `Dyno` is executed. This can for example be used to update any uniforms before running. |
| `globals` | Optional function that outputs global GLSL definitions needed by this block, as an array of strings. Duplicate global definition strings within the same program will be deduplicated, so you should always define all the globals you need for this block without regard to whether they are defined elsewhere. |
| `statements` | Optional function that outputs the GLSL statements to execute for this block as an array of strings. When defining a custom `Dyno` that executes GLSL, this is where your code will go. |
| `generate` | Called internally by the `dyno` compiler to generate the globals, statements, and any uniforms needed for this block. The default `generate` implementation will call `globals` and `statements` if they are defined and return these to the compiler. |
The methods `globals`, `statements`, and `generate` all take a `GenerateContext` object as argument, which consists of three properties:
| **Property** | **Description** |
|----------|-------------|
| `inputs` | A map from the input key to the *actual* GLSL variable name of that input value. The compiler will create unique names for values passed between blocks so you can reuse the same input key. If an input is NOT attached, the value will be `undefined`. |
| `outputs` | A map from the output key to the *actual* GLSL variable name of that output value. The compiler will create unique names for values passed between blocks so you can reuse the same output key. If an output is NOT connected, the value will be `undefined`. |
| `compile` | An instance of class `Compilation` that stores the context for the ongoing compilation, including global defines, statements to execute, uniforms, indentation level, etc. |
Typically you will want to define a `Dyno` subclass that constructs a `Dyno` instance with the appropriate `globals`, `statements`, and `update` etc. for your block. When generating code, use the passed in `GenerateContext` to get the names of inputs and outputs, and generate code that uses those names (via string interpolation or similar).
### Using a `Dyno`
Once defined, using your `Dyno` will typically be done as follows:
```typescript
const { sum } = new dyno.Add({ a, b }).outputs;
// Or equivalently:
const { sum } = dyno.add(a, b).outputs;
// For Dynos that implement HasDynoOut<T> you can also do
const sum = dyno.add(a, b);
```
The property `outputs` is a map from output name to a `DynoVal<T>` for each output type in `outTypes` (internally they are `DynoOutput`s).
For a `Dyno` that only has a single output, you can also implement the method `dynoOut(): DynoValue<T>` (which corresponds to the interface `HasDynoOut<T>`) to pick out a default output from `outputs`, which can help reduce redundant `.outputs` calls.
### Helper functions for GLSL code
`unindentLines(s: string): string[]`: Takes a multi-line string, removes common leading whitespace from each line, and returns the resulting array of strings. This can be useful to prepare an array of `statements` from a block of GLSL code:
```typescript
const leftAlignedLines = unindentLines(`
float sqr(float x) {
return x * x;
}
`);
```
`unindent(s: string): string`: Same as for `unindentLines` but joins the resulting lines into a single string. This is useful for defining a `globals`, which are de-duplicated based on the entirety of the string rather than individual lines.
## `class DynoBlock<InTypes, OutTypes>`
A `DynoBlock` is a `Dyno` that is like a "module" in that it can contain a `Dyno` subgraph internally that is created in a closure. Although you can construct a `DynoBlock` directly it is more ergnomic to use the helper function `dynoBlock`:
```typescript
const myGsplatGenerator = dyno.dynoBlock(
{ index: "int" }, // Inputs
{ gsplat: Gsplat }, // Outputs
({ index }) => { // Closure mapping inputs to outputs
let gsplat = dyno.readPackedSplat(myPackedSplats, index);
const opacity = dyno.dynoConst("float", 1.0);
gsplat = dyno.combineGsplat({ gsplat, opacity });
return { gsplat };
},
);
```
The first argument is mapping from input keys to `DynoType`, the second for output keys, and the third argument is a closure that constructs a `dyno` graph from the input `DynoVal`s and any other external `DynoVal`s (such as `myPackedSplats` in this example), and must return a map of `DynoVal`s that match the output keys. An optional fourth argument allows you to pass in additional arguments `update` (called before program execution) and `globals` (for any global definitions needed by the block).
## Helpers to design Dynos
When creating blocks that are simple operations of 1-3 inputs you can use the helper classes `UnaryOp`, `BinaryOp`, and `TrinaryOp`, which help standardize the interface. You must override the class' `statements` or `generate` method to generate the code. See examples in `src/dyno/math.ts` for how to use them.
+235
View File
@@ -0,0 +1,235 @@
# Dyno Standard Library
The Spark `dyno` system provides a standard library of `Dyno` blocks that cover most of the built-in functions in GLSL ES 3.0, including data conversion, logic, math, trigonometry, linear algebra, texture lookups, transforms, managing uniform variables, hashing & RNG, and of course managing splat data.
We use the convention of PascalCase for the names of the `Dyno` classes, and camelCase for the names of equivalent helper functions that are more ergonomic to use. For example, you can equivalently write:
```typescript
const sum = new dyno.Add({ a, b });
const sum = dyno.add(a, b);
```
The second form will create the `dyno.Add` class with appropriate constructor options. In the tables below we will opt for the camelCase variant for brevity.
## Data type conversion
The following functions use standard GLSL casting rules, which means that for example `bool(1)` will return `true` and `bool(0)` will return `false`.
| **Function** | **Description** |
|----------|-------------|
| `bool(value): DynoVal<"bool">` | Convert a value to a boolean |
| `int(value): DynoVal<"int">` | Convert a value to a signed integer |
| `uint(value): DynoVal<"uint">` | Convert a value to an unsigned integer |
| `float(value): DynoVal<"float">` | Convert a value to a floating-point number |
| `bvec<N>(value): DynoVal<"bvec<N>">` | Convert a value to a boolean vector of length N |
| `ivec<N>(value): DynoVal<"ivec<N>">` | Convert a value to a signed integer vector of length N |
| `uvec<N>(value): DynoVal<"uvec<N>">` | Convert a value to an unsigned integer vector of length N |
| `vec<N>(value): DynoVal<"vec<N>">` | Convert a value to a floating-point vector of length N |
| `mat<N>(value): DynoVal<"mat<M>">` | Convert a value to a NxN matrix |
| `floatBitsToInt(value: DynoVal<"float">): DynoVal<"int">` | Reinterpret the bits of a float as an integer |
| `floatBitsToUint(value: DynoVal<"float">): DynoVal<"uint">` | Reinterpret the bits of a float as an unsigned integer |
| `intBitsToFloat(value: DynoVal<"int">): DynoVal<"float">` | Reinterpret the bits of an integer as a float |
| `uintBitsToFloat(value: DynoVal<"uint">): DynoVal<"float">` | Reinterpret the bits of an unsigned integer as a float |
| `packSnorm2x16(value: DynoVal<"vec2">): DynoVal<"uint">` | Encode a vec2 from -1..1 as a 16-bit signed integer and pack into a 32-bit uint |
| `unpackSnorm2x16(value: DynoVal<"uint">): DynoVal<"vec2">` | Decode a 32-bit uint as a vec2 from -1..1 |
| `packUnorm2x16(value: DynoVal<"vec2">): DynoVal<"uint">` | Encode a vec2 from 0..1 as a 16-bit unsigned integer and pack into a 32-bit uint |
| `unpackUnorm2x16(value: DynoVal<"uint">): DynoVal<"vec2">` | Decode a 32-bit uint as a vec2 from 0..1 |
| `packHalf2x16(value: DynoVal<"vec2">): DynoVal<"uint">` | Encode a vec2 as two float16 values and pack into a 32-bit uint |
| `unpackHalf2x16(value: DynoVal<"uint">): DynoVal<"vec2">` | Decode a 32-bit uint as two float16 values |
| `uintToRgba8(value: DynoVal<"uint">): DynoVal<"vec4">` | Decode a 32-bit uint as a vec4 of 8-bit unsigned integers |
## Logic
The following logic and bit operations follow standard GLSL ES 3.0 semantics.
| **Function** | **Description** |
|----------|-------------|
| `and(a, b)` | Logical (for bool output) or bit-wise (for integer output) AND |
| `or(a, b)` | Logical (for bool output) or bit-wise (for integer output) OR |
| `xor(a, b)` | Logical (for bool output) or bit-wise (for integer output) XOR |
| `not(a)` | Logical NOT (for bool output) or bit-wise NOT (for integer output) |
| `lessThan(a, b)` | Less than |
| `lessThanEqual(a, b)` | Less than or equal to |
| `greaterThan(a, b)` | Greater than |
| `greaterThanEqual(a, b)` | Greater than or equal to |
| `equal(a, b)` | Equal |
| `notEqual(a, b)` | Not equal |
| `any(a: DynoVal<"bvec<N>">)` | True if any component of the vector is true |
| `all(a: DynoVal<"bvec<N>">)` | True if all components of the vector are true |
| `select(cond, t: DynoVal<T>, f: DynoVal<T>): DynoVal<T>` | Select between two values based on a condition |
| `compXor(a)` | Component-wise XOR of a boolean or integer vector |
## Math
The following math functions follow standard GLSL ES 3.0 semantics, for example rules around multiplication of vector and matrix types.
| **Function** | **Description** |
|----------|-------------|
| `add(a, b)` | Addition |
| `sub(a, b)` | Subtraction |
| `mul(a, b)` | Multiplication |
| `div(a, b)` | Division |
| `imod(a, b)` | Integer modulus |
| `mod(a, b)` | Floating-point modulus |
| `modf(a)` | Seperate a floating-point number into its fractional and integer parts |
| `neg(a)` | Negation |
| `abs(a)` | Absolute value |
| `sign(a)` | Sign of a number |
| `floor(a)` | Floor of a floating-point number |
| `ceil(a)` | Ceiling of a floating-point number |
| `trunc(a)` | Truncate a floating-point number toward negative infinity |
| `round(a)` | Round a floating-point number to the nearest integer |
| `fract(a)` | Fractional part of a floating-point number |
| `pow(a, b)` | a ^ b |
| `exp(a)` | e ^ a |
| `exp2(a)` | 2 ^ a |
| `log(a)` | Natural logarithm of a |
| `log2(a)` | Base-2 logarithm of a |
| `sqr(a)` | a * a |
| `sqrt(a)` | Square root of a |
| `inversesqrt(a)` | 1 / sqrt(a) |
| `min(a, b)` | Minimum of two values |
| `max(a, b)` | Maximum of two values |
| `clamp(a, min, max)` | Clamp a value between two others |
| `mix(a, b, t)` | Linear interpolation between two values |
| `step(edge, x)` | 0 if x < edge, 1 otherwise |
| `smoothstep(edge0, edge1, x)` | 0 if x <= edge0, 1 if x >= edge1, otherwise a smooth Hermite interpolation between 0 and 1 |
| `isNan(a)` | True if a is NaN |
| `isInf(a)` | True if a is infinite |
## Trigonometry
| **Function** | **Description** |
|----------|-------------|
| `radians(degrees)` | Convert degrees to radians |
| `degrees(radians)` | Convert radians to degrees |
| `sin(x)` | Sine of x |
| `cos(x)` | Cosine of x |
| `tan(x)` | Tangent of x |
| `asin(x)` | Arcsine of x |
| `acos(x)` | Arccosine of x |
| `atan(x)` | Arctangent of x |
| `atan2(y, x)` | Arctangent of y/x |
| `sinh(x)` | Hyperbolic sine of x |
| `cosh(x)` | Hyperbolic cosine of x |
| `tanh(x)` | Hyperbolic tangent of x |
| `asinh(x)` | Inverse hyperbolic sine of x |
| `acosh(x)` | Inverse hyperbolic cosine of x |
| `atanh(x)` | Inverse hyperbolic tangent of x |
## Linear Algebra
| **Function** | **Description** |
|----------|-------------|
| `length(a)` | Length of a vector |
| `distance(a, b)` | Distance between two vectors |
| `dot(a, b)` | Dot product of two vectors |
| `cross(a: DynoVal<"vec3">, b: DynoVal<"vec3">)` | Cross product of two 3-dimensional vectors |
| `normalize(a)` | Normalize a vector |
| `faceforward(a, b, c)` | Returns `a` if `dot(c, b) < 0`, otherwise returns `-a` |
| `reflectVec(incident, normal)` | Reflect a vector around a normal |
| `refractVec(incident, normal, eta)` | Refract a vector around a normal given an index of refraction |
| `split(a)` | Split a vector into its components |
| `combine(a)` | Create a vector from components, or inject components into an existing vector |
| `projectH(a)` | Project a vector in homogeneous coordinates |
| `extendVec(a, b)` | Extend a vector with an additional component |
| `swizzle(a, select)` | Select a subset of components from a vector |
| `compMult(a, b)` | Component-wise multiplication of two vectors |
| `outer(a, b)` | Outer product of two vectors |
| `transpose(a)` | Transpose a matrix |
| `determinant(a)` | Determinant of a matrix |
| `inverse(a)` | Invert a matrix |
## Texture lookups
| **Function** | **Description** |
|----------|-------------|
| `textureSize(texture, lod?)` | Return the size of a texture |
| `texture(texture, coord, bias?)` | Sample a texture at a continuous coordinate |
| `texelFetch(texture, coord, lod?)` | Fetch a discrete texel value from a texture |
## Transforms
| **Function** | **Description** |
|----------|-------------|
| `transformPos(position, { scale?, scales?, rotate?, translate? })` | Performs a transform of a position in 3D space, with optional uniform `scale`, anisotropic `scales`, quaternion `rotate`, and `translate` |
| `transformDir(dir, { scale?, scales?, rotate? })` | Performs a transform of a direction in 3D space, with optional uniform `scale`, anisotropic `scales`, and quaternion `rotate` |
| `transformQuat(quat, { rotate? })` | Rotate a quaternion |
## Uniform variables
Constant values and literals in `dyno` programs should not be changed often because it incurs a recompilation. To have a variable that can be changed every frame, you can declare a "uniform". The base class for uniforms provided by Spark is `DynoUniform`, which importantly contains a type, current value, and update function.
To update a uniform, simply assign a new value to the `value` property of the uniform. Alternatively, you can construct a `DynoUniform` with an `update` function that is called for each execution. This function can either update `value` directly, or return any non-`undefined` value to have it updated.
Use the following helper functions for more ergonomic creation of uniforms.
| **Function** | **Description** |
|----------|-------------|
| `dynoBool(value)` | Create a boolean uniform |
| `dynoUint(value)` | Create an unsigned integer uniform |
| `dynoInt(value)` | Create a signed integer uniform |
| `dynoFloat(value)` | Create a floating-point uniform |
| `dynoBvec<N>(value)` | Create a boolean vector of length N |
| `dynoUvec<N>(value)` | Create an unsigned integer vector of length N |
| `dynoIvec<N>(value)` | Create a signed integer vector of length N |
| `dynoVec<N>(value)` | Create a floating-point vector of length N |
| `dynoMat<N>(value)` | Create an NxN matrix |
| `dynoMat<N>x<M>(value)` | Create an NxM matrix |
| `dynoUsampler2D(texture)` | Create a uniform that lets you sample a 2D uint texture |
| `dynoIsampler2D(texture)` | Create a uniform that lets you sample a 2D int texture |
| `dynoSampler2D(texture)` | Create a uniform that lets you sample a 2D float texture |
| `dynoUsampler2DArray(texture)` | Create a uniform that lets you sample a 2D uint texture array |
| `dynoIsampler2DArray(texture)` | Create a uniform that lets you sample a 2D int texture array |
| `dynoSampler2DArray(texture)` | Create a uniform that lets you sample a 2D float texture array |
| `dynoUsampler3D(texture)` | Create a uniform that lets you sample a 3D uint texture |
| `dynoIsampler3D(texture)` | Create a uniform that lets you sample a 3D int texture |
| `dynoSampler3D(texture)` | Create a uniform that lets you sample a 3D float texture |
| `dynoUsamplerCube(texture)` | Create a uniform that lets you sample a cube uint texture |
| `dynoIsamplerCube(texture)` | Create a uniform that lets you sample a cube int texture |
| `dynoSamplerCube(texture)` | Create a uniform that lets you sample a cube float texture |
| `dynoSampler2DShadow(texture)` | Create a uniform that lets you sample a 2D float shadow texture |
| `dynoSampler2DArrayShadow(texture)` | Create a uniform that lets you sample a 2D float shadow texture array |
| `dynoSamplerCubeShadow(texture)` | Create a uniform that lets you sample a cube float shadow texture |
## Hashing & Random number generation
When a `dyno` program executes, each invocation for a given splat/index is effectively run in parallel, separate from the rest. In order to incorporate randomness into a `dyno` program, you must use the inputs available to the program, which is often just the `index` of the splat itself. Spark provides functions to hash any scalar or vector (integer or float) into 1-4 components of either a uint32 or float, using the PCG random number generator.
| **Function** | **Description** |
|----------|-------------|
| `pcgMix(value): DynoVal<"uint">` | Mix scalar or vector values into a single uint32 value that can be used as a seed for PCG |
| `pcgNext(state): DynoVal<"uint">` | Advance the PCG random number generator by one step |
| `pcgHash(state): DynoVal<"uint">` | Hash the PCG state into a random uint32 |
| `hash(value): DynoVal<"uint">` | Hash a scalar or vector value into a random uint32 |
| `hash2(value): DynoVal<"uvec2">` | Hash a scalar or vector value into a random uvec2 |
| `hash3(value): DynoVal<"uvec3">` | Hash a scalar or vector value into a random uvec3 |
| `hash4(value): DynoVal<"uvec4">` | Hash a scalar or vector value into a random uvec4 |
| `hashFloat(value): DynoVal<"float">` | Hash a scalar or vector value into a random float |
| `hashVec2(value): DynoVal<"vec2">` | Hash a scalar or vector value into a random vec2 |
| `hashVec3(value): DynoVal<"vec3">` | Hash a scalar or vector value into a random vec3 |
| `hashVec4(value): DynoVal<"vec4">` | Hash a scalar or vector value into a random vec4 |
## Splat data
Spark makes it easier to work with splat data by defining the GLSL struct `Gsplat` which contains the following fields:
| **Field** | **Type** | **Description** |
|----------|-------------|-------------|
| `center` | `vec3` | Center of the splat |
| `flags` | `uint` | Flags for the splat (0x1 = active) |
| `scales` | `vec3` | Scales of the splat |
| `index` | `int` | Index of the splat in the array |
| `quaternion` | `vec4` | Quaternion orientation of the splat |
| `rgba` | `vec4` | RGBA color of the splat |
This way, you can pass around a `DynoVal<Gsplat>` that contains the all the properties of a splat. The following helper functions are provided to extract the splat data from a `PackedSplats`:
| **Function** | **Description** |
|----------|-------------|
| `numPackedSplats(packedSplats)` | Get the number of splats in a `PackedSplats` |
| `readPackedSplat(packedSplats, index)` | Read a particular splat from a `PackedSplats` by index |
| `readPackedSplatRange(packedSplats, index, base, count)` | Read a particular splat from a `PackedSplats` by index but restricted to the given range |
| `splitGsplat(gsplat)` | Split a `Gsplat` into its components |
| `combineGsplat(gsplat)` | Create a `Gsplat` from components (or inject components into an existing `Gsplat`) |
| `gsplatNormal(gsplat)` | Get the `Gsplat` normal, defined as whichever X/Y/Z axis has the smallest scale |
| `transformGsplat(gsplat, { scale?, rotate?, translate?, recolor? })` | Transform a `Gsplat` and all its components by the given transform |
+70
View File
@@ -0,0 +1,70 @@
# Getting Started
## Quick Start
Copy and paste code below in an `index.html` file or remix in the [Web Playground](https://stackblitz.com/edit/spark?file=index.html)
```html
<style> body {margin: 0;} </style>
<script type="importmap">
{
"imports": {
"three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/0.178.0/three.module.js",
"@sparkjsdev/spark": "https://sparkjs.dev/releases/spark/0.1.10/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SplatMesh } from "@sparkjsdev/spark";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement)
const splatURL = "https://sparkjs.dev/assets/splats/butterfly.spz";
const butterfly = new SplatMesh({ url: splatURL });
butterfly.quaternion.set(1, 0, 0, 0);
butterfly.position.set(0, 0, -3);
scene.add(butterfly);
renderer.setAnimationLoop(function animate(time) {
renderer.render(scene, camera);
butterfly.rotation.y += 0.01;
});
</script>
```
## Install with NPM
```shell
npm install @sparkjsdev/spark
```
## Develop and contribute to Spark
Build Spark (It requires [Rust](https://www.rust-lang.org/tools/install) installed in your machine)
```
npm install
npm run dev
```
This will run a Web server at [http://localhost:8080/](http://localhost:8080/) with the examples.
## Table of Contents
- [Spark Overview](overview.md)
- [System Design](system-design.md)
- [SparkRenderer](spark-renderer.md)
- [SparkViewpoint](spark-viewpoint.md)
- [SplatMesh](splat-mesh.md)
- [PackedSplats](packed-splats.md)
- [Loading Gsplats](loading-splats.md)
- [Procedural Splats](procedural-splats.md)
- [Splat RGBA-XYZ SDF editing](splat-editing.md)
- [Dyno overview](dyno-overview.md)
- [Dyno standard library](dyno-stdlib.md)
- [Controls](controls.md)
- [Performance tuning](performance.md)
- [Community Resources](community-resources.md)
+89
View File
@@ -0,0 +1,89 @@
# Loading Splats
Spark provides loaders for most popular splat file formats, including `.ply` (original "gsplat" format, compressed SuperSplat/gsplat variant, and plain x/y/z/r/g/b point clouds) and `.spz` (Niantic open source compressed format), both auto-detected from the file contents.
Spark can also load formats `.splat` (from [`antimatter15/splat`](https://github.com/antimatter15/splat)) and `.ksplat` (from [`mkkellogg/GaussianSplats3D`](https://github.com/mkkellogg/GaussianSplats3D)) if the file type can be inferred from the URL/path extension, or set explicitly using the `fileType` property when creating a `SplatMesh` or `PackedSplats`.
## Loading auto-detectable formats `.ply` and `.spz`
Adding an individual `SplatMesh` from an auto-detectable format is easy and can be done as simply as:
```javascript
// Load and create SplatMesh in one go
const splats = new SplatMesh({ url: "./butterfly.ply" });
scene.add(splats);
// No file extension but we can auto-detect format from the contents
scene.add(new SplatMesh({ url: "plyBin/0123456789abcdef" }));
scene.add(new SplatMesh({ url: "spzBin/fedcba9876543210" }));
```
### Load via `PackedSplats`
Alternatively, you can load a `.ply` or `.spz` into a `PackedSplats`, which can then be used as an input source for multiple `SplatMesh` instances in the scene.
```javascript
const packedSplats = new PackedSplats({ url: "./clone.ply" });
const splats1 = new SplatMesh({ packedSplats });
scene.add(splats1);
const splats2 = new SplatMesh({ packedSplats });
scene.add(splats2);
```
### Load via `SplatLoader`
Finally, you can make use of the `THREE.Loader` infrastructure via the `SplatLoader` class. A `Loader` has a synchronous and asynchronous interface. You can provide progress meters during downloads and invoke completion callbacks as follows:
```javascript
const loader = new SplatLoader();
loader.loadAsync(url, (event) => {
if (event.type === "progress") {
const progress = event.lengthComputable
? `${((event.loaded / event.total) * 100).toFixed(2)}%`
: `${event.loaded} bytes`;
console.log(`Background download progress: ${progress}`);
}
})
.then((packedSplats) => {
const splatMesh = new SplatMesh({ packedSplats });
// Re-orient from OpenCV to OpenGL coordinates
splatMesh.quaternion.set(1, 0, 0, 0);
splatMesh.position.set(0, 0, -1);
splatMesh.scale.setScalar(0.5);
scene.add(splatMesh);
})
.catch((error) => {
console.warn(error);
});
```
## Loading additional formats `.splat` and `.ksplat`
These formats cannot be reliably auto-detected from the file contents, so we use two fall-back mechanism to enable support for these formats.
First, the auto-detection fails on these files, which triggers file type inference via URL/path file extension. If the URL contains the `.splat` or `.ksplat` extensions (stripping out query parameters etc.), we set the corresponding file type.
```javascript
const splats = new SplatMesh({ url: "./butterfly.splat" });
scene.add(splats);
const ksplats = new SplatMesh({ url: "./butterfly.ksplat" });
scene.add(ksplats);
```
If the URL contains a path with no obvious file extension, you can set the field `fileType` when constructing a `SplatMesh` or `PackedSplats`.
```javascript
// File type can't be auto-detected, so we set it explicitly
scene.add(new SplatMesh({
url: "splatBin/0123456789abcdef",
fileType: SplatFileType.SPLAT,
}));
scene.add(new SplatMesh({
url: "ksplatBin/fedcba9876543210",
fileType: SplatFileType.KSPLAT,
}));
```
+24
View File
@@ -0,0 +1,24 @@
*Spark aims to expand what's possible with Gaussian Splatting and help 3D/4D creators bring their visions to life and share it with others.*
# Overview
3D Gaussian Splatting has emerged as a frontrunner in generative AI and 3D reconstruction. By representing 3D scenes and objects as collections of tiny oriented Gaussian-shaped blobs (aka "splats"), machine learning techniques can be used to create detailed, photorealistic 3D content that can be rendered in real-time. However, 3DGS is a relatively new technique that that can't be used in many traditional triangle-based 3D mesh rendering engines. Tools for creating, editing, and rendering 3DGS are in their infancy, mostly able to only work with a single, static 3DGS object at time.
The web's most popular 3D graphics library THREE.js, can't render 3DGS directly. Although libraries exist to render 3DGS on the web, they each come with different limitations and don't treat 3DGS objects as first-class citizens in the scene hierarchy. Limitations include: rendering only one 3DGS object at time, incorrect occlusion between 3DGS objects, inability to dynamically modify the objects, requiring WebGPU, or slow/laggy rendering. We believe 3DGS will play an important role in future 3D/4D content creation, and we built Spark to make it easy to incorporate 3DGS into online experiences.
## Spark
Spark is a dynamic 3DGS renderer built for THREE.js and WebGL2 that runs in any web browser (desktop, mobile, and WebXR). With a handful of lines of code, anyone using THREE.js can easily add 3DGS to their scenes (even by vibe coding!). By creating one or more `SplatMesh` objects and adding them to your scene, Spark will render these alongside traditional triangle-based meshes during your regular `render(scene, camera)` call. `SplatMesh` derives from `THREE.Object3D` and can be translated and rotated like any other object, placed arbitrarily in the scene hierarchy, and animated by adjusting the values each frame. A `SplatMesh` can be created from most splat file formats via the `url` parameter or by directly creating the splats one by one.
3DGS is still in its infancy, and we expect new techniques will be developed for recoloring/relighting, animation, transitions, and other creative or interactive effects. We designed Spark to be a programmable splat engine from the ground up, giving you unprecedented control over how individual splats are generated, animated, and rendered into the scene. Similar to shader graph systems in modern 3D graphics engines, Spark allows you to compose blocks of functions (called `Dyno`s) into computation graphs that can generate splats procedurally, modify them arbitrarily, or anything other computation you can imagine, and will be converted to GLSL to run on the GPU.
## Features
- Integrates with THREE.js rendering pipeline to fuse splat and mesh-based objects
- Portable: Works across almost all devices, targeting 98%+ WebGL2 support
- Renders fast even on low-powered mobile devices
- Render multiple splat objects together with correct sorting
- Most major splat file formats supported including .PLY (also compressed), .SPZ, .SPLAT, .KSPLAT
- Render multiple viewpoints simultaneously
- Fully dynamic: each splat can be transformed and edited for animation
- Real-time splat color editing, displacement, and skeletal animation
- Shader graph system to dynamically create/edit splats on the GPU
+190
View File
@@ -0,0 +1,190 @@
# PackedSplats
A `PackedSplats` is a collection of Gaussian splats, packed into a format that takes exactly 16 bytes per splat to maximize memory and cache efficiency. The `center` xyz coordinates are encoded as float16 (3 x 2 bytes), `scale` xyz as 3 x uint8 that encode a log scale from e^-12 to e^9, `rgba` as 4 x uint8, and quaternion encoded via axis+angle using 2 x uint8 for octahedral encoding of the axis direction and a uint8 to encode rotation amount from 0..Pi.
## Creating a `PackedSplats`
```typescript
const packedSplats = new PackedSplats({
// Fetch PLY/SPZ/SPLAT/KSPLAT file from URL
url?: string;
// Decode raw PLY/SPZ/SPLAT/KSPLAT file bytes
fileBytes?: Uint8Array | ArrayBuffer;
// Override file type
fileType?: SplatFileType;
// Reserve space for at least this many splats for construction
maxSplats?: number;
// Use provided packed data array, 4 words per splat
packedArray?: Uint32Array;
// Override number of splats in packed array to a subset
numSplats?: number;
// Constructor callback to create splats
construct?: (splats: PackedSplats) => Promise<void> | void;
// Extra splat data, such as sh1..3 components
extra?: Record<string, unknown>;
});
```
### Optional parameters
Like for `SplatMesh` you can create a `new PackedSplats()` with no options, which will create a new empty instance with 0 splats. Similarly, you can provide an input `url` or `fileBytes` to decode from a file source. You can also create a `PackedSplats` from a raw `Uint32Array` where each successive 4 Uint32 values encodes one "packed" splat. Finally, a `construct(splats)` callback provides an ergonomic way to create splats procedurally with an in-line initialization closure.
| **Parameter** | Description |
| ----------------- | ----------- |
| **url** | URL to fetch a Gaussian splat file from (supports .ply, .splat, .ksplat, .spz formats). (default: `undefined`)
| **fileBytes** | Raw bytes of a Gaussian splat file to decode directly instead of fetching from URL. (default: `undefined`)
| **fileType** | Override the file type detection for formats that can't be reliably auto-detected (.splat, .ksplat). (default: `undefined` auto-detects other formats from file contents)
| **maxSplats** | Reserve space for at least this many splats when constructing the collection initially. The array will automatically resize past maxSplats so setting it is an optional optimization. (default: `0`)
| **packedArray** | Use provided packed data array, where each 4 consecutive uint32 values encode one "packed" splat. (default: `undefined`)
| **numSplats** | Override number of splats in packed array to use only a subset. (default: length of packed array / 4)
| **construct** | Callback function to programmatically create splats at initialization. (default: `undefined`)
| **extra** | Additional splat data, such as spherical harmonics components (sh1, sh2, sh3). (default: `{}`)
## Encoding / Decoding
Utility functions are provided in Javascript to pack/unpack these encodings:
```javascript
// Set via packedSplats interface
packedSplats.setSplat(index, center, scales, quaternion, opacity, color);
// Set underlying Uint32 array directly
import { utils } from "@sparkjsdev/spark";
utils.setPackedSplat(packedSplats.packedArray, index, x, y, z, scaleX, scaleY, ...);
// Set rotation components of underlying Uint32 array directly
utils.setPackedSplatQuat(packedSplats.packedArray, index, quatX, quatY, quatZ, quatW);
// Unpack all splat components from the Uint32 array
const { center, scales, quaternion, color, opacity } = utils.unpackSplat(packedSplats.packedArray, index);
// Unpack all splats with callback
packedSplats.forEachSplat((index, center, scales, quaternion, opacity, color) => {
// Use unpacked splat data. Changing the inputs directly has no effect.
// Update just the scales component
utils.setPackedSplatScales(packedSplat.packedArray, index, 0.005, 0.01, 0.015);
// Update the entire splat
packedSplat.setSplat(index, center, scales, quaternion, opacity, color);
});
```
In GLSL code you can use the following utility functions that are available via `splatDefines.glsl`, which is included in all GLSL shader programs:
```glsl
// Pack a splat into a uvec4
uvec4 packSplat(vec3 center, vec3 scales, vec4 quaternion, vec4 rgba);
// Unpack a splat from a uvec4
void unpackSplat(uvec4 packed, out vec3 center, out vec3 scales, out vec4 quaternion, out vec4 rgba);
```
In `dyno` shader contexts you can read and unpack a splat from a `PackedSplats`:
```javascript
// Fetch and unpack a particular index from a PackedSplats.
const gsplat = dyno.readPackedSplat(packedSplats.dyno, index);
```
### Byte Layout
Each `PackedSplat` occupies 16 bytes (4 × `uint32`), with the following layout of fields by byte offset:
| Offset (bytes) | Field | Size (bytes) | Description |
|----------------|-----------------|--------------|------------------------------------------------------------|
| 0 | R | 1 | Red color channel (uint8 0–255 → 0.0–1.0) |
| 1 | G | 1 | Green color channel (uint8 0–255 → 0.0–1.0) |
| 2 | B | 1 | Blue color channel (uint8 0–255 → 0.0–1.0) |
| 3 | A | 1 | Alpha (opacity) channel (uint8 0–255 → 0.0–1.0) |
| 4–5 | center.x | 2 | X coordinate of splat center (float16) |
| 6–7 | center.y | 2 | Y coordinate of splat center (float16) |
| 8–9 | center.z | 2 | Z coordinate of splat center (float16) |
| 10 | quat oct.U | 1 | Octahedral quaternion U component (uint8) |
| 11 | quat oct.V | 1 | Octahedral quaternion V component (uint8) |
| 12 | scale.x | 1 | X scale, log-encoded to uint8 |
| 13 | scale.y | 1 | Y scale, log-encoded to uint8 |
| 14 | scale.z | 1 | Z scale, log-encoded to uint8 |
| 15 | quat angle (θ) | 1 | Encoded quaternion rotation angle (uint8, θ/π·255) |
### Splat RGBA encoding
RGB values are encoded are uint8 sRGB values with 0..255 mapping to 0..1. When loading from a PLY file these values are derived by calculating `ply[f_dc_0] * SH_C0 + 0.5`.
Opacity is encoded on a linear scale where 0..255 maps to 0..1.
### Splat center encoding
The center x/y/z components are encoded as float16, which provides 10 bits of mantissa, or approximately 1K steps (0.1%) of resolution between each successive power of 0 from the origin, with a range of up to 32K in distance. If most of the splats are positioned relative to the origin this provides enough positional resolution. Splats that are transformed far from the origin, however (for example when bringing multiple `SplatMesh`es together in a scene that are far apart) may lose precision when mapped to the space of `SparkRenderer`. For scenes where the user camera may move far from the origin, you may want to tie the `SparkRenderer` origin to your camera by adding it as a child of the camera.
### Splat scales encoding
The XYZ scales are encoded independently using the following mapping: Any scale values below e^-30 are interpreted as "true zero" scale, and encoded as `uint8(0)`. Any other values quantized by computing `ln(scale_xyz)`, mapping the range e^-12..e^9 to uint8 values 1..255, rounding, and clamping. This logarithmic scale range can encode values from 0.0001 up to 8K in scale, with approximately 7% steps between discrete sizes, and has minimal impact on perceptible visual quality.
### Splat orientation encoding
We encode a splat's quaternion/orientation by encoding it explicitly in an axis + angle representation: 8 bits for each U/V coordinate for octahedral encoding of the axis direction, and 8 bits to encode the rotation angle range 0..Pi.
This representation was chosen over other internal rotation representations because it provides a good mix of speed/simplicity, uniformity of representable orientations, and especially its ability to handle rotations "near identity". Other encodings such as the more common "3 quaternion components" tend to have poor rotational resolution around I(1), which is a particularly important regime of this parameter.
### `extra` splat data
Each instance of `PackedSplats` also has a property `extra: Record<string, unknown>` that is used to attach additional splat-related data to the `PackedSplats` container. For example, spherical harmonics degrees 1..3 are stored in `sh1: Uint32Array(numSplats * 2)`, `sh2: Uint32Array(numSplats * 4)`, `sh3: Uint32Array(numSplats * 4)`, and intermediate textures are generated by `SplatMesh` and stored as `sh1Texture` etc.
This structure can be used to extend and store additional data in a `PackedSplats`, but there is no specific convention yet to prevent collisions.
`sh1` stores each of 3 x 3 RGB signed components as Sint7 (mapping -1..1) in 63 bits (8 bytes) per splat. `sh2` stores each of 5 x 3 RGB signed components as Sint8 in 120 bits (16 bytes). `sh3` stores each of 7 x 3 RGB signed components as Sint6 in 126 bits (16 bytes) to improve memory bandwidth efficiency. Note that using spherical harmonics dramatically increases the memory footprint from 16 bytes per splat up to 56 bytes per splat with SH0..3, which can impact rendering performance.
## `PackedSplats` instance methods
### `dispose()`
Call this when you are finished with the PackedSplats and want to free any render targets + textures it holds.
### `ensureSplats(numSplats)`
Ensures that `this.packedArray` can fit `numSplats` splats. If it's too small, resize exponentially and copy over the original data.
Typically you don't need to call this, because calling `this.setSplat(index, ...)` and `this.pushSplat(...)` will automatically call `ensureSplats()` so we have enough splats.
### `getSplat(index): { center, scales, quaternion, opacity, color }`
Unpack the 16-byte splat data at `index` into the THREE.js components `center: THREE.Vector3`, `scales: THREE.Vector3`, `quaternion: THREE.Quaternion`, `opacity: number 0..1`, `color: THREE.Color 0..1`.
### `setSplat(index, center, scales, quaternion, opacity, color)`
Set all PackedSplat components at `index` with the provided splat attributes (can be the same objects returned by `getSplat`). Ensures there is capacity for at least `index+1` splats.
### `pushSplat(center, scales, quaternion, opacity, color)`
Effectively calls `this.setSplat(this.numSplats++, center, ...)`, useful on construction where you just want to iterate and create a collection of splats.
### `forEachSplat(callback: (index, center, scales, quaternion, opacity, color) => void)`
Iterate over splats index `0..=(this.numSplats-1)`, unpack each splat and invoke the callback function with the splat attributes.
### `getTexture()`
Returns a `THREE.DataArrayTexture` representing the PackedSplats content as a Uint32x4 data array texture (2048 x 2048 x depth in size)
### `getEmpty()`
Can be used where you need an uninitialized `THREE.DataArrayTexture` like a uniform you will update with the result of `this.getTexture()` later.
## Generating splats on the GPU
To generate a large number of splats we can use the `dyno` shader graph system, which allows you to create a computation graph mapping `{ index: DynoVal<"int"> }` to `{ gsplat: DynoVal<Gsplat> }` via Javascript code, then have that synthesize GLSL code, which is finally compiled and executed in parallel on the GPU.
This building block is used by `SparkRenderer` to traverse each visible `SplatMesh`/`SplatGenerator` and have it "generate" its splats into the global `PackedSplats` array managed by a `SplatAccumulator`. At its core a `PackedSplats` has the ability to run `dyno` computation graphs to produce its contents using the following methods, which are typically managed by `SparkRenderer`:
### `generateMapping(splatCounts: number[]): { maxSplats, mapping[] }`
Given an array of splatCounts (`.numSplats` for each `SplatGenerator`/`SplatMesh` in the scene), compute a "mapping layout" in the composite array of generated outputs.
### `ensureGenerate(maxSplats)`
Ensures our `PackedSplats.target` render target has enough space to generate `maxSplats` total splats, and reallocate if not large enough.
### `generate({ generator, base, count, ... })`
Executes a `dyno` program specified by `generator` which is any `DynoBlock` that maps `{ index: "int" }` to `{ gsplat: Gsplat }`. This is invoked from `SparkRenderer.updateInternal()` to re-generate splats in the scene for `SplatGenerator` instances whose version is newer than last generated version.
## Using dynamic PackedSplats inputs in `dyno`
You can use a `PackedSplats` in a `dyno` block using the function `dyno.readPackedSplats(packedSplats.dyno, dynoIndex)` where `dynoIndex` is of type `DynoVal<"int">` If you need to be able to change the input `PackedSplats` dynamically, however, you should create a `DynoPackedSplats`, whose property `packedSplats` you can set to any `PackedSplats`, which will be used in the next `dyno` shader program execution.
+24
View File
@@ -0,0 +1,24 @@
# Performance Tuning
Rendering millions of splats at 60+ fps can be a demanding task, especially for mobile-class GPUs. Each splat is rendered as two triangles that span the footprint of a Gaussian up to `sqrt(8)` standard deviations (default value) from the center. Each splat is rendered as a transparent object and must be blended back-to-front.
As a quick rule-of-thumb, the following "splat budgets" are recommended:
- Quest 3: 1 million splats or less, not too many splats concentrated in a small area
- Android phone: 1-2 million splats
- iPhone: 1-3 million splats
- Computer: 1-5 million splats (10-20+ million on some desktops)
Each splat incurs overhead in transforming it via SplatAccumulator for sorting and rendering, and at around 1 million splats this becomes a bottleneck on some systems. Unintuitively, when a large number of splats are concentrated in a small area (for example 500K splats from a Trellis object at a small screen scale) they can bottleneck the GPU's rendering and blending ability.
## `maxStdDev`
Adjust `SparkRenderer.maxStdDev` (either directly on `SparkRenderer` or via constructor options) to a value less than the default `Math.sqrt(8)`. This limits the extent of the Gaussian fall-off, which by default is approx 2.8. For VR a good value is `Math.sqrt(5)`, which is perceptually very similar to the default.
## `THREE.WebGLRenderer.antialias`
When constructing a `THREE.WebGLRenderer` you should set `antialias: false` (default value) to avoid the overhead of multisampling. Rendering splats doesn't benefit from multisampling, and adds a significant amount of overhead when drawing millions of splats.
## `renderer.setPixelRatio(window.devicePixelRatio)`
Although `SparkRenderer` is designed to work with any `devicePixelRatio`, it may impact performance due to the increased number of pixels to render and blend. If your scene consists of mostly splats, consider whether you have enough splats to justify the high DPI rendering.
+290
View File
@@ -0,0 +1,290 @@
# Procedural Splats
Spark makes it easy to create Gaussian splat collections procedurally, and includes some splat constructors that are useful for common tasks like creating a grid or text made of splats. The example "Procedural Splats" puts some of these to use in a scene.
## Adding splats to a collection
To create a `PackedSplats` with custom, procedurally-derived splats use the methods `pushSplat` or `setSplat`:
```javascript
const splats = new PackedSplats();
const center = new THREE.Vector3(0, 0, 0);
const scales = new THREE.Vector3(0.1, 0.1, 0.1);
const quaternion = new THREE.Quaternion();
const opacity = 1.0;
const color = new THREE.Color()
splats.pushSplat(center, scales, quaternion, opacity, color);
...
```
Note that calling `.pushSplat` and `.setSplat` will automatically resize the `PackedSplats` array to fit the splat. Additionally, the center, scales, etc parameters are encoded into 16 bytes, and the original objects passed in (such as `center: THREE.Vector3`) are no longer used. Re-using these objects for all the splats is a good idea.
Alternatively, you can use the `construct` initializer callback:
```javascript
const splats = new PackedSplats({
construct: (splats) => {
...
for (let i = 0; i < NUM_SPLATS; ++i) {
// Compute splat #i
...
splats.pushSplat(center, scales, quaternion, opacity, color);
}
},
});
```
Once you've created your `PackedSplats`, render it to the scene via a `SplatMesh`:
```javascript
const mesh = new SplatMesh({ packedSplats: splats });
scene.add(mesh);
```
Alternatively, you can create the `SplatMesh` and its splats in the initializer, which internally passes your constructor to its contained `PackedSplats`:
```javascript
const mesh = new SplatMesh({
constructSplats: (splats) => {
for (let i = 0; i < NUM_SPLATS; ++i) {
// Compute splat #i
...
splats.pushSplat(center, scales, quaternion, opacity, color);
}
},
});
scene.add(mesh);
```
## Grid
```javascript
import { constructGrid } from "@sparkjsdev/spark";
const grid = new SplatMesh({
constructSplats: (splats) => constructGrid({
splats,
extents: new THREE.Box3(
new THREE.Vector3(-10, -10, -10),
new THREE.Vector3(10, 10, 10),
),
}),
});
scene.add(grid);
```
### Required parameters
| Parameter | Description |
|-----------|-------------|
| `splats` | PackedSplats object to add splats to |
| `extents` | min and max box extents of the grid |
### Optional parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `stepSize` | `1` | step size along each grid axis |
| `pointRadius` | `0.01` | spherical radius of each splat |
| `pointShadowScale` | `2.0` | relative size of the "shadow copy" of each splat placed behind it |
| `opacity` | `1.0` | splat opacity |
| `color` | RGB-modulated grid | splat color (THREE.Color) or function to set color for position: ((THREE.Color, THREE.Vector3) => void) |
## XYZ axis
```javascript
import { constructAxes } from "@sparkjsdev/spark";
const axes = new SplatMesh({
constructSplats: (splats) => constructAxes({ splats }),
});
axes.position.set(0, 0, -1);
scene.add(axes);
```
### Required parameters
| Parameter | Description |
|-----------|-------------|
| `splats` | PackedSplats object to add splats to |
### Optional parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `scale` | `0.25` | scale (splat scale along axis) |
| `axisRadius` | `0.0075` | radius of the axes (splat scale orthogonal to axis) |
| `axisShadowScale` | `2.0` | relative size of the "shadow copy" of each splat placed behind it |
| `origins` | `[new THREE.Vector3()]` | origins of the axes (default single axis at origin) |
## Splat sphere
```javascript
import { constructSpherePoints } from "@sparkjsdev/spark";
const sphere = new SplatMesh({
constructSplats: (splats) => constructSpherePoints({
splats,
maxDepth: 4,
}),
});
scene.add(sphere);
```
### Required parameters
| Parameter | Description |
|-----------|-------------|
| `splats` | PackedSplats object to add splats to |
### Optional parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `origin` | `new THREE.Vector3()` | center of the sphere (default: origin) |
| `radius` | `1.0` | radius of the sphere |
| `maxDepth` | `3` | maximum depth of recursion for subdividing the sphere. Warning: splat count grows exponentially with depth |
| `filter` | `null` | filter function to apply to each point, for example to select points in a certain direction or other function ((THREE.Vector3) => boolean) |
| `pointRadius` | `0.02` | radius of each oriented splat |
| `pointThickness` | `0.001` | flatness of each oriented splat |
| `color` | `new THREE.Color(1, 1, 1)` | color of each splat (THREE.Color) or function to set color for point: ((THREE.Color, THREE.Vector3) => void) |
## Rasterizing Text
```typescript
const splats = textSplats({
text: string;
font?: string;
fontSize?: number;
color?: THREE.Color;
rgb?: THREE.Color;
dotRadius?: number;
textAlign?: "left" | "center" | "right" | "start" | "end";
lineHeight?: number;
});
scene.add(splats);
```
### Required parameters
| Parameter | Description |
|-----------|-------------|
| `text` | text string to display |
### Optional parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `font` | `"Arial"` | browser font to render text with |
| `fontSize` | `32` | font size in pixels/splats |
| `color` | `new THREE.Color(1, 1, 1)` | SplatMesh.recolor tint assuming white splats |
| `rgb` | `new THREE.Color(1, 1, 1)` | Individual splat color |
| `dotRadius` | `0.8` | splat radius (0.8 covers 1-unit spacing well) |
| `textAlign` | `"start"` | text alignment: "left", "center", "right", "start", "end" |
| `lineHeight` | `1.0` | line spacing multiplier, lines delimited by "\n" |
## Turning images into splats
```typescript
const image = imageSplats({
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;
});
scene.add(image);
```
### Required parameters
| Parameter | Description |
|-----------|-------------|
| `url` | URL of the image to convert to splats (example: `url: "./image.png"`) |
### Optional parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `dotRadius` | `0.8` | Radius of each splat, default covers 1-unit spacing well |
| `subXY` | `1` | Subsampling factor for the image. Higher values reduce resolution, for example 2 will halve the width and height by averaging |
| `forEachSplat` | `undefined` | Optional callback function to modify each splat before it's added. Return null to skip adding the splat, or a number to set the opacity and add the splat 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. |
### Example
```javascript
// Load RGBA image from image.png, subsample it 2x
// horizontally and vertically, and create splats for
// the resulting pixels that have at least 10% opacity.
const image = imageSplats({
url: "./image.png",
subXY: 2,
forEachSplat: (width, height, index, center, scales, quaternion, opacity, color) => {
// Only keep splats with opacity 10% or higher
return (opacity >= 0.1) ? opacity : null;
},
});
scene.add(image);
```
## Particle Effects
Some of the building blocks are meant to serve as "code inspiration", showing how a particle effect animation can be achieved using a "stateless" `dyno` computation graph that uses only the splat `index` input to produce pseudo-random numbers that drive various randomized particle effects.
Note that splat sorting takes a little bit of time and can "lag behind" the splat updates each frame, so it's important that there is a reasonably stable correspondence between each frame's splat output for the same `index`.
### `generators.staticBox(...options)`
In `staticBox` you provide a 3D box to render random "static" splats within. The AABB is sliced up into a 3D grid of cells, and random X/Y/Z points within those cells are sampled each frame, retaining consistency in splat position between successive frames.
### `generators.snowBox(...options)`
Similarly, `snowBox` produces splat trajectories that move in a deterministic fashion over time, with high similarity between adjacent frames. See "VFX - Particle Simulation" for an example that creates a `snowBox`.
A snowBox instance has a collection of properties that can be tuned to achieve different particle effects. DEFAULT_SNOW and DEFAULT_RAIN are example parameter sets that look a lot like snow and rain, and can be used as a starting point for further tweaking: `const mySnow = { ...DEFAULT_SNOW, density: 500 };`
```typescript
const snowControls = generators.snowBox({
box,
minY,
numSplats,
density,
anisoScale,
minScale,
maxScale,
fallDirection,
fallVelocity,
wanderScale,
wanderVariance,
color1,
color2,
opacity,
onFrame,
});
scene.add(snowControls.snow);
```
| Parameter | Default Value | Description |
|-----------|---------------|-------------|
| `box` | `new THREE.Box3(new THREE.Vector3(-1, -1, -1), new THREE.Vector3(1, 1, 1))` | min and max box extents of the snowBox |
| `minY` | `Number.NEGATIVE_INFINITY` | minimum y-coordinate to clamp particle position, which can be used to fake hitting a ground plane and lingering there for a bit |
| `numSplats` | calculated from box and density | number of splats to generate |
| `density` | `100` | density of splats per unit volume |
| `anisoScale` | `new THREE.Vector3(1, 1, 1)` | The xyz anisotropic scale of the splat, which can be used for example to elongate rain particles |
| `minScale` | `0.001` | Minimum splat particle scale |
| `maxScale` | `0.005` | Maximum splat particle scale |
| `fallDirection` | `new THREE.Vector3(0, -1, 0)` | The average direction of fall |
| `fallVelocity` | `0.02` | The average speed of the fall (multiplied with fallDirection) |
| `wanderScale` | `0.01` | The world scale of wandering overlay motion |
| `wanderVariance` | `2` | Controls how uniformly the particles wander in sync, more variance means more randomness in the motion |
| `color1` | `new THREE.Color(1, 1, 1)` | Color 1 of the two colors interpolated between |
| `color2` | `new THREE.Color(0.5, 0.5, 1)` | Color 2 of the two colors interpolated between |
| `opacity` | `1` | The base opacity of the splats |
| `onFrame` | `undefined` | Optional callback function to call each frame |
+95
View File
@@ -0,0 +1,95 @@
# SparkRenderer
## Optionally adding to your `THREE.Scene`
Spark internally uses a `SparkRenderer` object in your `THREE.Scene` to perform splat rendering. Spark will automatically create a `SparkRenderer` and add it to your scene if you don't create one yourself. For more advanced use cases such as multiple viewpoints or rendering environment maps, you can create your own `SparkRenderer` and add it anywhere in the scene, for example at the root:
```typescript
const spark = new SparkRenderer({
renderer: myThreeJsWebGlRenderer,
});
const scene = new THREE.Scene();
scene.add(spark);
```
## Larger scenes
All scene splats are accumulated by SplatAccumulator into a single global PackedSplats, whose coordinates are relative to the SparkRenderer's origin. Splats that are far away from this origin may exhibit float16 quantization artifacts, so if you plan on moving the camera large distances you can instead add `SparkRenderer` as a child of your `THREE.Camera`, ensuring that coordinates will have higher precision near the camera viewpoint:
```javascript
const aspect = canvas.width / canvas.height;
const camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000);
scene.add(camera);
// Add SparkRenderer as a child of camera to follow it
camera.add(spark);
```
## Creating a `SparkRenderer`
```typescript
const spark = new SparkRenderer({
renderer: THREE.WebGLRenderer;
clock?: THREE.Clock;
autoUpdate?: boolean;
preUpdate?: boolean;
originDistance?: number;
maxStdDev?: number;
enable2DGS?: boolean;
preBlurAmount?: number;
blurAmount?: number;
focalDistance?: number;
apertureAngle?: number;
falloff?: number;
clipXY?: number;
focalAdjustment?: number;
view?: SparkViewpointOptions;
});
```
### Required parameters
| **Parameter** | Description |
| ------------- | ----------- |
| **renderer** | Pass in your `THREE.WebGLRenderer` instance so Spark can perform work outside the usual render loop. Should be created with `antialias: false` (default setting) as WebGL anti-aliasing doesn't improve Gaussian Splatting rendering and significantly reduces performance.
### Optional parameters
| **Parameter** | Description |
| ----------------- | ----------- |
| **clock** | Pass in a `THREE.Clock` to synchronize time-based effects across different systems. Alternatively, you can set the `SparkRenderer` properties `time` and `deltaTime` directly. (default: `new THREE.Clock`)
| **autoUpdate** | Controls whether to check and automatically update splat collection after each frame render. (default: `true`)
| **preUpdate** | Controls whether to update the splats before or after rendering. For WebXR this *must* be false in order to complete rendering as soon as possible. (default: `false`)
| **originDistance** | Distance threshold for `SparkRenderer` movement triggering a splat update at the new origin. (default: `1.0`) This can be useful when your `SparkRenderer` is a child of your camera and you want to retain high precision coordinates near the camera.
| **maxStdDev** | Maximum standard deviations from the center to render Gaussians. Values `Math.sqrt(5)`..`Math.sqrt(9)` produce good results and can be tweaked for performance. (default: `Math.sqrt(8)`)
| **minPixelRadius** | Minimum pixel radius for splat rendering. (default: `0.0`)
| **maxPixelRadius** | Maximum pixel radius for splat rendering. (default: `512.0`)
| **minAlpha** | Minimum alpha value for splat rendering. (default: `0.5 * (1.0 / 255.0)`)
| **enable2DGS** | Enable 2D Gaussian splatting rendering ability. When this mode is enabled, any `scale` x/y/z component that is exactly `0` (minimum quantized value) results in the other two non-zero axes being interpreted as an oriented 2D Gaussian Splat instead of the usual approximate projected 3DGS Z-slice. When reading PLY files, scale values less than e^-30 will be interpreted as `0`. (default: `false`)
| **preBlurAmount** | Scalar value to add to 2D splat covariance diagonal, effectively blurring + enlarging splats. In scenes trained without the splat anti-aliasing tweak this value was typically 0.3, but with anti-aliasing it is 0.0 (default: `0.0`)
| **blurAmount** | Scalar value to add to 2D splat covariance diagonal, with opacity adjustment to correctly account for "blurring" when anti-aliasing. Typically 0.3 (equivalent to approx 0.5 pixel radius) in scenes trained with anti-aliasing.
| **focalDistance** | Depth-of-field distance to focal plane (default: `0.0`)
| **apertureAngle** | Full-width angle of aperture opening from pinhole camera origin in radians (default: `0.0` to disable)
| **falloff** | Modulate Gaussian kernel falloff. 0 means "no falloff, flat shading", while 1 is the normal Gaussian kernel. (default: `1.0`)
| **clipXY** | X/Y clipping boundary factor for splat centers against view frustum. 1.0 clips any centers that are exactly out of bounds (but the splat's entire projection may still be in bounds), while 1.4 clips centers that are 40% beyond the bounds. (default: `1.4`)
| **focalAdjustment** | Parameter to adjust projected splat scale calculation to match other renderers, similar to the same parameter in the MKellogg 3DGS renderer. Higher values will tend to sharpen the splats. A value 2.0 can be used to match the behavior of the PlayCanvas renderer. (default: `1.0`)
| **view** | Configures the `SparkViewpointOptions` for the default `SparkViewpoint` associated with this `SparkRenderer`. Notable option: `sortRadial` (sort by radial distance or Z-depth)
## `newViewpoint(options: SparkViewpointOptions)`
Create a new `SparkViewpoint` for this `SparkRenderer`. Note that every `SparkRenderer` has an initial `spark.defaultView: SparkViewpoint` created during construction, which is used for default canvas rendering. Calling this method allows you to create additional viewpoints, which can be updated automatically each frame (performing splat sorting every time there is an update), or updated on-demand for controlled rendering for video frame rendering or similar applications.
## `update({ scene })`
If `spark.autoUpdate` is `false` then you must manually call `spark.update({ scene })` to have the scene splats re-generated.
## `renderEnvMap({ renderer, scene, worldCenter, ... })`
Renders out the scene to an environment map that can be used for image-based lighting or similar applications. First updates splats, sorts them with respect to the provided `worldCenter`, renders 6 cube faces, then pre-filters them using `THREE.PMREMGenerator` and returns a `THREE.Texture` that can assigned directly to a `THREE.MeshStandardMaterial.envMap` property.
## `recurseSetEnvMap(root, envMap)`
Utility function to recursively set the `envMap` property for any `THREE.MeshStandardMaterial` within the subtree of `root`.
## `getRgba({ generator, ... })`
Utility function that helps extract the splat RGBA values from a `SplatGenerator`, including the result of any real-time RGBA SDF edits applied to a `SplatMesh`. This effectively "bakes" any computed RGBA values, which can now be used as a pipeline input via `SplatMesh.splatRgba` to inject these baked values into the splat data.
## `readRgba({ generator, ...})`
Utility function that builds on `getRgba({ generator })` and additionally reads back the RGBA values to the CPU in a Uint8Array with packed RGBA in that byte order.
+84
View File
@@ -0,0 +1,84 @@
# SparkViewpoint
A `SparkViewpoint` is created from and tied to a `SparkRenderer`, and represents an independent viewpoint of all the scene splats and their sort order. Making these viewpoints explicit allows us to have multiple, simultaneous viewpoint renders, for example for camera preview panes or overhead map views.
When creating a `SparkRenderer` it automatically creates a default viewpoint `.defaultView` that is used in the normal render loop when drawing to the canvas, and is automatically updated whenever the camera moves. Additional viewpoints can be created and configured separately:
## Creating a `SparkViewpoint`
```typescript
const viewpoint = spark.newViewpoint({
autoUpdate?: boolean;
camera?: THREE.Camera;
viewToWorld?: THREE.Matrix4;
target?: {
width: number;
height: number;
doubleBuffer?: boolean;
superXY?: number;
};
onTextureUpdated?: (texture: THREE.Texture) => void;
sortRadial?: boolean;
sortDistance?: number;
sortCoorient?: boolean;
depthBias?: number;
sort360?: boolean;
sort32?: boolean;
stochastic?: boolean;
});
```
### Optional parameters
| **Parameter** | Description |
| ----------------- | ----------- |
| **autoUpdate** | Controls whether to auto-update its sort order whenever the SparkRenderer updates the splats. If you expect to render/display from this viewpoint most frames, set this to `true`. (default: `false`)
| **camera** | Set a `THREE.Camera` for this viewpoint to follow. (default: `undefined`)
| **viewToWorld** | Set an explicit view-to-world transformation matrix for this viewpoint (equivalent to `camera.matrixWorld`), overrides any `camera` setting. (default: `undefined`)
| **target** | Configure viewpoint with an off-screen render target. (default: `undefined`)
| **target.width** | Width of the render target in pixels.
| **target.height** | Height of the render target in pixels.
| **target.doubleBuffer** | If you want to be able to render a scene that depends on this target's output (for example, a recursive viewport), set this to `true` to enable double buffering. (default: `false`)
| **target.superXY** | Super-sampling factor for the render target. Values 1-4 are supported. Note that re-sampling back down to `.width` x `.height` is done on the CPU with simple averaging only when calling `readTarget()`. (default: `1`)
| **onTextureUpdated** | Callback function that is called when the render target texture is updated. Receives the texture as a parameter. Use this to update a viewport with the latest viewpoint render each frame. (default: `undefined`)
| **sortRadial** | Whether to sort splats radially (geometric distance) from the viewpoint (true) or by Z-depth (false). Most scenes are trained with the Z-depth sort metric and will render more accurately at certain viewpoints. However, radial sorting is more stable under viewpoint rotations. (default: `true`)
| **sortDistance** | Distance threshold for re-sorting splats. If the viewpoint moves more than this distance, splats will be re-sorted. (default: `0.01` units)
| **sortCoorient** | View direction dot product threshold for re-sorting splats. For `sortRadial: true` it defaults to 0.99 while `sortRadial: false` uses 0.999 because it is more sensitive to view direction. (default: `0.99` if `sortRadial` else `0.999`)
| **depthBias** | Constant added to Z-depth to bias values into the positive range for `sortRadial: false`, but also used for culling splats "well behind" the viewpoint origin (default: `1.0`)
| **sort360** | Set this to true if rendering a 360 to disable "behind the viewpoint" culling during sorting. This is set automatically when rendering 360 envMaps using the `SparkRenderer.renderEnvMap()` utility function. (default: `false`)
| **sort32** | Set this to true to sort with float32 precision with two-pass sort. (default: `false`)
| **stochastic** | Set this to true to enable sort-free stochastic splat rendering. (default: `false`)
## `dispose()`
Call this when you are done with the `SparkViewpoint` and want to free up its resources (GPU targets, pixel buffers, etc.)
## `setAutoUpdate(autoUpdate: boolean)`
Use this function to change whether this viewpoint will auto-update its sort order whenever the attached `SparkRenderer` updates the splats. Turn this on or off depending on whether you expect to do renders from this viewpoint for most frames.
## `async prepareRenderPixels({ scene, camera?, viewToWorld?, update?, forceOrigin? })`
Render out a viewpoint as a Uint8Array of RGBA values for the provided scene and any `camera`/`viewToWorld` viewpoint overrides. By default `update` is `true`, which triggers its `SparkRenderer` to check and potentially update the splats. Setting `update` to `false` disables this and sorts the splats as they are. Setting `forceOrigin` (default: `false`) to `true` forces the view update to recalculate the splats with this view origin, potentially altering any view-dependent effects. If you expect view-dependent effects to play a role in the rendering quality, enable this.
Underneath, `prepareRenderPixels()` simply calls `await this.prepare(...)`, `this.renderTarget(...)`, and finally returns the result of `this.readTarget()`, a Promise to a Uint8Array with RGBA values for all the pixels (potentially downsampled if the `superXY` parameter was used). These steps can also be called manually, for example if you need to alter the scene before and after `this.renderTarget(...)` to hide UI elements from being rendered.
## `async prepare({ scene, camera?, viewToWorld?, update?, forceOrigin? })`
See above `async prepareRenderPixels()` for explanation of parameters. Awaiting this method updates the splats in the scene and performs a sort of the splats from this viewpoint, preparing it for a subsequent `this.renderTarget()` call in the same tick.
## `renderTarget({ scene, camera? })`
Render out the viewpoint to the view target RGBA buffer. Swaps buffers if `doubleBuffer: true` was set. Calls `onTextureUpdated(texture)` with the resulting texture.
## `async readTarget()`
Read back the previously rendered target image as a Uint8Array of packed RGBA values (in that order). If `superXY` was set greater than `1` than downsampling is performed in the target pixel array with simple averaging to derive the returned pixel values. Subsequent calls to `this.readTarget()` will reuse the same buffers to minimize memory allocations.
## `autoPoll()`
This is called automatically by `SparkRenderer`, there is no need to call it! The method cannot be private because then SparkRenderer would not be able to call it.
## `SparkViewpoint.EMPTY_TEXTURE`
If you need an empty `THREE.Texture` to use to initialize a uniform that is updated via `onTextureUpdated(texture)`, this static texture can be handy.
+88
View File
@@ -0,0 +1,88 @@
# Splat Editing
Spark provides the ability to apply "edits" to splats as part of the standard `SplatMesh` pipeline. These edits take the form of a sequence of operations, applied one at a time to the set of splats in its `packedSplats`. Each operation evaluates a 7-dimensional field (RGBA and XYZ displacement) at each splat's center point in space that derives from 1 or more Signed Distance Field shapes (such as spheres, boxes, planes, etc.), blended together and across inside-outside boundaries.
The result is a an RGBA,XYZ value for each splat, which combined with SplatEditRgbaBlendMode.MULTIPLY/SET_RGB/ADD_RGBA can be used to create special effects. For example, simulating simple lighting can be done with MULTIPLY or ADD_RGBA to light up or adjust the color in regions of space, using spheres for point light sources or infinite cone for a spotlight. Using MULTIPLY with opacity=0 can be used to delete splats from a region of space. The splats can also be displaced in space using the XYZ values, and adjusted each frame to create smooth deformations in space.
## Creating a splat edit operation
A `SplatEdit` operation can be assigned to a particular `SplatMesh` through its `.edits[]` property or by adding the `SplatEdit` as a child of the `SplatMesh` in the scene hierarchy. If the `SplatEdit` has no `SplatMesh` ancestor, its edits will apply globally to all `SplatMesh`es whose `editable` property is set to the default `true`.
```typescript
const edit = new SplatEdit({
name?: string;
rgbaBlendMode?: SplatEditRgbaBlendMode;
sdfSmooth?: number;
softEdge?: number;
invert?: boolean;
sdfs?: SplatEditSdf[];
});
scene.add(edit);
```
### Optional parameters
| Parameter | Default | Description |
|-----------------|------------------------------------------|--------------------------------------------------------------------------------------------------------------|
| `name` | `undefined` (auto‐generated to `Edit <n>`) | Name of this edit operation. If you omit it, a default `"Edit 1"`, `"Edit 2"`, … is assigned. |
| `rgbaBlendMode` | `SplatEditRgbaBlendMode.MULTIPLY` | How the SDF’s RGBA modifies each splat’s RGBA: multiply, overwrite RGB, or add RGBA. |
| `sdfSmooth` | `0.0` | Smoothing (in world‐space units) for blending between multiple SDF shapes at their boundaries. |
| `softEdge` | `0.0` | Soft‐edge falloff radius (in world‐space units) around each SDF shape’s surface. |
| `invert` | `false` | Invert the SDF evaluation (inside/outside swap). |
| `sdfs` | `null` | Explicit array of `SplatEditSdf` objects to include. If `null`, any child `SplatEditSdf` instances are used. |
### RGBA blend modes
When creating a `SplatEdit` you can specify a `rgbaBlendMode?: SplatEditRgbaBlendMode` value from the enum to choose between 3 blend modes:
| Blend Mode | Description |
|-------------------------------------------|----------------------------------------------------------------------------------------------------------------------|
| `MULTIPLY` | The RGBA of the splat is multiplied component-wise by the SDF’s RGBA value at that point in space. (default) |
| `SET_RGB` | Ignore the Alpha value in the SDF, but set the splat’s RGB to equal the SDF’s RGB value at that point. |
| `ADD_RGBA` | Add the SDF’s RGBA value at that point to the RGBA value of the splat. This can produce hyper-saturated results, but is useful to easily “light up” areas. Note that having non-zero opacity is often not what you want because low-opacity splats will be made more opaque.|
## Adding an SDF RGBA-XYZ shape to the edit operation
```typescript
const shape1 = new SplatEditSdf({
type?: SplatEditSdfType;
invert?: boolean;
opacity?: number;
color?: THREE.Color;
displace?: THREE.Vector3;
radius?: number;
});
edit.add(shape1);
```
### Optional parameters
| Parameter | Default | Description |
|-------------|------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| `type` | `SPHERE` | The SDF shape type: `ALL`, `PLANE`, `SPHERE`, `BOX`, `ELLIPSOID`, `CYLINDER`, `CAPSULE`, or `INFINITE_CONE`. |
| `invert` | `false` | Invert the SDF evaluation, swapping inside and outside regions. |
| `opacity` | `1.0` | Opacity / "alpha" value used differently by blending modes |
| `color` | `Color(1, 1, 1)` | RGB color applied within the shape. |
| `displace` | `Vector3(0, 0, 0)` | XYZ displacement applied to splat positions inside the shape. |
| `radius` | `0.0` | Shape-specific size parameter: sphere radius, box corner rounding, cylinder/capsule radius, or for the infinite cone the angle factor (opening half-angle = π/4 × `radius`). |
### SDF Shapes
The following SDF shapes are available in the `SplatEditSdfType` enum:
| SDF Type | Description | SDF Parameters |
|----------|-------------|------------|
| `ALL` | Affects all points in space | None |
| `PLANE` | Infinite plane | position, rotation |
| `SPHERE` | Sphere | position, radius |
| `BOX` | Box (with optional corner rounding) | position, rotation, sizes, radius |
| `ELLIPSOID` | Ellipsoid | position, rotation, sizes |
| `CYLINDER` | Cylinder | position, rotation, size_y |
| `CAPSULE` | Capsule | position, rotation, size_y |
| `INFINITE_CONE` | Infinite cone | position, rotation, radius=angle |
## Adding multiple SDF RGBA-XYZ shapes to the edit operation
RGBA-XYZ values are computed by blending together values from all SDF shapes using the exponential "softmax" function, which is commutative (so blending order within a `SplatEdit` operation doesn't matter). The parameter `SplatEdit.sdfSmooth` controls the blending scale *between* SDF shapes, while `SplatEdit.softEdge` controls the scale of soft inside-outside shape boundaries. Their default values start at `0.0` and should be increased to soften the edges.
Note that XYZ displacement values are blended in the same way as RGBA, with a resulting displacement field that can be quite complex but "softly" blends between shapes. These RGBA-XYZ edits, along with time-based and overlapping fields can create many interesting animations and special effects, such as rippling leaves in the wind, an angry fire, or a looping water effect. Simply update the `SplatEdit` and `SplatEditSdf` objects and the operations will be applied immediately to the splats in the scene.
+130
View File
@@ -0,0 +1,130 @@
# SplatMesh
A `SplatMesh` is a high-level interface for displaying and manipulating a "Splat mesh", a collection of Gaussian splats that serves as an object of sorts. It is analagous to a traditional triangle-based `THREE.Mesh`, which consists of geometry (points and triangles) and materials (color and lighting). Similarly, a `SplatMesh` contains geometry (splat centers, orientation, and xyz scales) and materials (RGB color, opacity, spherical harmonics), and can be added anywhere in the scene hierarchy.
The usual THREE.js properties `position`, `quaternion`, `rotation` behave as you would expect, however `scale` only allows uniform scaling and averages the x/y/z scales. Additional properties `recolor` and `opacity` are multiplied in with the final splat color and opacity.
`SplatMesh` is a subclass of the more fundamental `SplatGenerator`, which itself is a subclass of `THREE.Object3D`. Any methods and properties on `Object3D` are also available in `SplatMesh`. `SplatGenerator` gives you more control over splat generation and modification, but `SplatMesh` has an simpler higher-level API.
## Creating a `SplatMesh`
```typescript
const splats = new SplatMesh({
// Fetch PLY/SPZ/SPLAT/KSPLAT file from URL
url?: string;
// Decode raw PLY/SPZ/SPLAT/KSPLAT file bytes
fileBytes?: Uint8Array | ArrayBuffer;
// Override file type
fileType?: SplatFileType;
// Use PackedSplats object as source
packedSplats?: PackedSplats;
// Reserve space for at least this many splats for construction
maxSplats?: number;
// Constructor callback to create splats
constructSplats?: (splats: PackedSplats) => Promise<void> | void;
// Callback for when mesh initialization is complete
onLoad?: (mesh: SplatMesh) => Promise<void> | void;
// Toggle controls whether SplatEdits have an effect, default true
editable?: boolean;
// Frame callback to update mesh. Call mesh.updateVersion() if we need to re-generate
onFrame?: ({
mesh,
time,
deltaTime,
}: { mesh: SplatMesh; time: number; deltaTime: number }) => void;
// Object-space and world-space splat modifiers to apply
objectModifier?: GsplatModifier;
worldModifier?: GsplatModifier;
});
// Add to scene to show splats
scene.add(splats);
```
### Optional parameters
You can create a `new SplatMesh()` with no options, which will create a new default instance with `.numSplats=0`. Alternatively, you can provide an input `url` to fetch and decode, `fileBytes`, or `packedSplats` (an existing collection of "packed" splats). Spark supports most splat file types, including .ply (including SuperSplat/gsplat compressed), .splat, .ksplat, .spz. To load filetypes .splat and .ksplat (which can't be reliably auto-detected), use the optional `fileType` argument.
Constructor argument callbacks can be used like `constructSplats` to create a collection of splats procedurally at initialization, `onLoad` when loading and initialization completes, `onFrame` to update state every frame. Splat effects can be injected into the standard splat processing pipeline that operate in object-space and world-space via `objectModifier` and `worldModifier` respectively.
| **Parameter** | Description |
| ----------------- | ----------- |
| **url** | URL to fetch a Gaussian splat file from (supports .ply, .splat, .ksplat, .spz formats). (default: `undefined`)
| **fileBytes** | Raw bytes of a Gaussian splat file to decode directly instead of fetching from URL. (default: `undefined`)
| **fileType** | Override the file type detection for formats that can't be reliably auto-detected (.splat, .ksplat). (default: `undefined` auto-detects other formats from file contents)
| **packedSplats** | Use an existing PackedSplats object as the source instead of loading from a file. Can be used to share a collection of splats among multiple `SplatMesh`es (default: `undefined` creates a new empty `PackedSplats` or decoded from a data source above)
| **maxSplats** | Reserve space for at least this many splats when constructing the mesh initially. (default: determined by file)
| **constructSplats** | Callback function to programmatically create splats at initialization in a newly created `PackedSplats`. (default: `undefined`)
| **onLoad** | Callback function that is called when mesh initialization is complete. (default: `undefined`)
| **editable** | Controls whether SplatEdits have any effect on this mesh. (default: `true`)
| **onFrame** | Callback function that is called every frame to update the mesh. Call `mesh.updateVersion()` if splats need to be regenerated due to some change. Calling `updateVersion()` is not necessary for object transformations, recoloring, or opacity adjustments as these changes are auto-detected. (default: `undefined`)
| **objectModifier** | Splat modifier to apply in object-space before any transformations. A `GsplatModifier` is a `dyno` shader-graph block that transforms an input `gsplat: DynoVal<Gsplat>` to an output `gsplat: DynoVal<Gsplat>` with `gsplat.center` and other parameters in object-space. (default: `undefined`)
| **worldModifier** | Splat modifier to apply in world-space after transformations. (default: `undefined`)
## Instance properties
The constructor argument options `packedSplats`, `editable`, `onFrame`, `objectModifier`, and `worldModifier` can be modified directly on the `SplatMesh`.
If you modify `packedSplats` you should set `splatMesh.packedSplats.needsUpdate = true` to signal to THREE.js that it should re-upload the data to the underlying texture. Use this sparingly with objects with lower splat counts as it requires a CPU-GPU data transfer for each frame. Thousands to tens of thousands of splats is reasonable. (See `hands.ts` for an example of rendering "splat hands" in WebXR using this technique.)
If you modify `objectModifier` or `worldModifier` you should call `splatMesh.updateGenerator()` to update the pipeline and have it compile to run efficiently on the GPU.
Additional properties on a `SplatMesh` instance:
| **Property** | Description |
| ----------------- | ----------- |
| **initialized** | A `Promise<SplatMesh>` you can await to ensure fetching, parsing, and initialization has completed
| **isInitialized** | A `boolean` indicating whether initialization is complete
| **recolor** | A `THREE.Color` that can be used to tint all splats in the mesh. (default: `new THREE.Color(1, 1, 1)`)
| **opacity** | Global opacity multiplier for all splats in the mesh. (default: `1`)
| **context** | A `SplatMeshContext` consisting of useful scene and object `dyno` uniforms that can be used to in the splat processing pipeline, for example via `objectModifier` and `worldModifier`. (created on construction)
| **enableViewToObject** | Set to `true` to have the `viewToObject` property in `context` be updated each frame. If the mesh has `extra.sh1` (first order spherical harmonics directional lighting) this property will always be updated. (default: `false` )
| **enableViewToWorld** | Set to `true` to have `context.viewToWorld` updated each frame. (default: `false`)
| **enableWorldToView** | Set to `true` to have `context.worldToView` updated each frame. (default: `false`)
| **skinning** | Optional `SplatSkinning` instance for animating splats with dual-quaternion skeletal animation. (default: `null`)
| **edits** | Optional list of `SplatEdit`s to apply to the mesh. If `null`, any `SplatEdit` children in the scene graph will be added automatically. (default: `null`)
| **splatRgba** | Optional `RgbaArray` to overwrite splat RGBA values with custom values. Useful for "baking" RGB and opacity edits into the `SplatMesh`. (default: `null`)
| **maxSh** | Maximum Spherical Harmonics level to use. Spark supports up to SH3. Call `updateGenerator()` after changing. (default: `3`)
## `dispose()`
Call this when you are finished with the `SplatMesh` and want to free any buffers it holds (via `packedSplats`).
## `pushSplat(center, scales, quaternion, opacity, color)`
Creates a new splat with the provided parameters (all values in "float" space, i.e. 0-1 for opacity and color) and adds it to the end of the `packedSplats`, increasing `numSplats` by 1. If necessary, reallocates the buffer with an exponential doubling strategy to fit the new data, so it's fairly efficient to `pushSplat(...)` each splat you want to create in a loop.
## `forEachSplat(callback: (index, center, scales, quaternion, opacity, color) => void)`
This method iterates over all splats in this instance's `packedSplats`, invoking the provided callback with `index: number` in `0..=(this.numSplats-1)`, `center: THREE.Vector3`, `scales: THREE.Vector3`, `quaternion: THREE.Quaternion`, `opacity: number` (0..1), and `color: THREE.Color` (rgb values in 0..1). Note that the objects passed in as `center` etc. are the same for every callback invocation: they are reused for efficiency. *Changing these values has no effect* as they are decoded/unpacked copies of the underlying data. To update the `packedSplats`, call `.packedSplats.setSplat(index, center, scales, quaternion, opacity, color)`.
## `getBoundingBox(centers_only=true)`
This method returns a `THREE.Box3` representing the axis-aligned bounding box of all splats in the mesh.
The parameter `centers_only` (boolean, default: `true`) controls whether we calculate the bounding box using only splat center positions, or include the full extent of each splat by considering their scales and orientations. The latter gives a slightly more accurate but more computationally expensive bounding box.
Note that this function will raise an error if called before splats are initialized.
## `updateGenerator()`
Call this whenever something changes in the splat processing pipeline, for example changing `maxSh` or updating `objectModifier` or `worldModifier`. Compiled generators are cached for efficiency and re-used when the same graph structure emerges after successive changes.
## `update(...)`
This is called automatically by `SparkRenderer` and you should not have to call it. It updates parameters for the generated pipeline and calls `updateGenerator()` if the pipeline needs to change.
## `raycast(raycaster, intersects: { distance, point, object}[])`
This method conforms to the standard `THREE.Raycaster` API, performing object-ray intersections using this method to populate the provided `intersects[]` array with each intersection point's `distance: number`, `point: THREE.Vector3`, and `object: SplatMesh`. Note that this method is synchronous and uses a WebAssembly-based ray-splat intersection algorithm that iterates over all points. Raycasting against millions of splats have a noticeable delay, and should not be called every frame.
Usage example:
```javascript
const raycaster = new THREE.Raycaster();
canvas.addEventListener("click", (event) => {
raycaster.setFromCamera(new THREE.Vector2(
(event.clientX / canvas.width) * 2 - 1,
-(event.clientY / canvas.height) * 2 + 1,
), camera);
const intersects = raycaster.intersectObjects(scene.children);
const splatIndex = intersects.findIndex((i) => i.object instanceof SplatMesh);
});
```
+30
View File
@@ -0,0 +1,30 @@
# System Design
One of the biggest challenges in real-time Gaussian splat rendering is sorting the splats so that they can be drawn and blended in back-to-front order, known as the Painter's algorithm.
## Rendering data flow cycle
`SparkRenderer` is a key component in Spark that manages this process. It traverses the visible THREE.js scene graph and compiles a complete list of all splats across the scene, generated by instances of `SplatMesh` in the scene hierarchy.
Each `SparkRenderer` has a default `SparkViewpoint` that reads back a list of all splat viewpoint distances from the GPU, then determines the splat draw order using an efficient bucket sort algorithm, run in a background worker thread via `SplatWorker`. You can spawn additional `SparkViewpoint`s to create multiple simultaneous render viewpoints.
Finally, on the next THREE.js render() call, `SparkRenderer` invokes a single instanced geometry draw call to draw all the scene's splats in the correct back-to-front order, merging with other opaque THREE.js geometry using the Z buffer. The sort order lags the render by at least one frame, but possibly more on older devices, but is not usually perceptible.
```typescript
// Optionally add a SparkRenderer to the scene to manage SplatMesh rendering.
// If none is created, Spark will create one for you automatically.
const spark = new SparkRenderer({ renderer: webGlRenderer });
scene.add(spark);
```
This design allows splats from distinct objects/scenes to coexist in space and sort correctly w.r.t. each other's splats. Splats from independent `SplatMesh`es are aggregated using a `SplatAccumulator`, which produces a `PackedSplats`, a collection splats stored in a cache-efficient 16-byte/splat format.
## "Programmable Splats"
Spark also uses this opportunity to run a user-programmable data pipeline on each splat on the GPU. The standard pipeline provides high-level functionality, such as applying rigid transforms, adjusting RGB / opacity, and spherical harmonics, but also color editing and perturbations (via `SplatEdit`) and a dual-quaternion skeletal animation system (`SplatSkinning`). The standard pipeline also allows injecting arbitrary code to modify each splat via `dyno` shader graph system.
`SplatMesh` derives from a more general base class `SplatGenerator`, which itself derives from `THREE.Object3D`. As such, it can be placed anywhere in the scene hierarchy and obeys expected local and global coordinate transforms. A `SplatGenerator` is the most general form of a "splat object", whose splats are produced programmatically via a `dyno` shader graph function that maps `{ index: "int" }` to `{ gsplat: "Gsplat" }`. A `SplatMesh` is a higher-level object that implements such a mapping, reading source splats from a template (loaded via a `url` constructor parameter or otherwise) at the given `index`, then applying functions such as transforming to world space.
In contrast, implementing a `SplatGenerator` gives you full control to write any function that programmatically computes a splat's attributes (center, scales, quaternion, rgba). These could be stateless (relying only on `index`, random-number generators, etc), or could rely on a complex combination of splat files, textures, and other global parameters for real-time procedural generation, and can vary with time to produce real-time animations.
The `dyno` shader graph system allows you to create these programmatic pipelines with Javascript code, which is synthesized into GLSL code and compiled and run on the GPU. This `dyno` system powers other components of Spark as well, such as `Readback` (which can perform any computation and read back the resulting value), used to compute the sort distance metric for pairs of splats and read them back for CPU sorting.
+16
View File
@@ -0,0 +1,16 @@
---
title: Home
hide:
- navigation
- toc
---
<div class="hero">
<h1><img src="/assets/images/logo-hero.png"/></h1>
<h2>An advanced 3D Gaussian Splatting renderer for THREE.js</h2>
<div class="feature-list">
Integrate in your scene with other meshes and splats, fast rendering on all devices, programmable dynamic splat effects, wide format support (ply, <a href="https://github.com/fraunhoferhhi/Self-Organizing-Gaussians">sogs</a>, spz, splat, ksplat)
</div>
<a href="/docs/" class="md-button md-button--primary">Get started →</a>
<iframe class="hero-image" src="/examples/hello-world/carousel.html"></iframe>
</div>
+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block extrahead %}
{{ super() }}
<meta name="description" content="An advanced 3D Gaussian Splatting renderer for THREE.js">
<!-- Facebook Meta Tags -->
<meta property="og:url" content="https://sparkjs.dev">
<meta property="og:type" content="website">
<meta property="og:title" content="Spark">
<meta property="og:description" content="An advanced 3D Gaussian Splatting renderer for THREE.js">
<meta property="og:image" content="https://opengraph.b-cdn.net/production/images/763a8fd8-7dad-4f91-ab27-e36a889c2d54.png?token=3c6AgBfDRclxcs5EBSW5KdHsgL0c5WObagFZHS6Xh9w&height=630&width=1200&expires=33285588022">
<!-- Twitter Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta property="twitter:domain" content="sparkjs.dev">
<meta property="twitter:url" content="https://sparkjs.dev">
<meta name="twitter:title" content="Spark">
<meta name="twitter:description" content="An advanced 3D Gaussian Splatting renderer for THREE.js">
<meta name="twitter:image" content="https://opengraph.b-cdn.net/production/images/763a8fd8-7dad-4f91-ab27-e36a889c2d54.png?token=3c6AgBfDRclxcs5EBSW5KdHsgL0c5WObagFZHS6Xh9w&height=630&width=1200&expires=33285588022">
<!-- Meta Tags Generated via https://www.opengraph.xyz -->
<style>
.hero {
text-align: center;
}
.hero h1 {
font-size: 3rem;
margin-bottom: 0;
}
.hero h2 {
margin-top: 1rem;
margin-bottom: 2rem;
}
.hero p {
font-size: 1.2rem;
margin-bottom: 0rem;
}
.hero iframe.hero-image {
width: 100%;
max-width: 800px;
height: 600px;
border-radius: 15px;
display: block;
margin: auto;
margin-top: 2rem;
}
.hero img {
display: block;
max-width: 550px;
margin:auto;
}
@media screen and (max-width: 60em) {
.hero img {
max-width: 320px;
margin:auto;
}
.hero h2 {
font-size: 22px;
}
}
</style>
{% endblock %}
@@ -0,0 +1,35 @@
<header class="md-header" data-md-component="header">
<nav class="md-header__inner md-grid md-custom-header" aria-label="Header">
<!-- Left: Logo -->
<div class="md-header__title">
<a href="/" class="md-header__button md-logo">
<img src="/assets/images/logo.svg" alt="{{ config.site_name }}">
</a>
</div>
<label class="md-header__button md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"></path></svg>
</label>
{% include "partials/search.html" %}
<!-- Right: Custom links + search -->
<div class="md-custom-header-right">
<a href="/examples/" class="md-nav__link">Examples</a>
<a href="/docs/" class="md-nav__link">Docs</a>
<a href="/viewer/" class="md-nav__link">Viewer</a>
<a rel="noopener noreferrer" class="contrast" aria-label="Discord channel" href="https://discord.gg/W39qmSKemS" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="icon-discord" viewBox="0 -2 16 16">
<path d="M13.545 2.907a13.2 13.2 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.2 12.2 0 0 0-3.658 0 8 8 0 0 0-.412-.833.05.05 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.04.04 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032q.003.022.021.037a13.3 13.3 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019q.463-.63.818-1.329a.05.05 0 0 0-.01-.059l-.018-.011a9 9 0 0 1-1.248-.595.05.05 0 0 1-.02-.066l.015-.019q.127-.095.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.05.05 0 0 1 .053.007q.121.1.248.195a.05.05 0 0 1-.004.085 8 8 0 0 1-1.249.594.05.05 0 0 0-.03.03.05.05 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.2 13.2 0 0 0 4.001-2.02.05.05 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.03.03 0 0 0-.02-.019m-8.198 7.307c-.789 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612m5.316 0c-.788 0-1.438-.724-1.438-1.612s.637-1.613 1.438-1.613c.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612"/>
</svg>
</a>
</div>
<a rel="noopener noreferrer" class="contrast" aria-label="GitHub repository" href="https://github.com/sparkjsdev/spark" target="_blank" style="margin-top: 5px;">
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 496 475" class="icon-github">
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path>
</svg>
</a>
</div>
</nav>
</header>
+175
View File
@@ -0,0 +1,175 @@
@font-face {
font-family: "Inter";
src: url(https://fonts.googleapis.com/css2?family=Cinzel:wght@400..900&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Limelight&display=swap);
}
:root {
--color-dark: white;
--color-light: black;
--md-primary-fg-color: #d43e4c;
--md-accent-fg-color: #cb6065;
--md-text-font: "Inter";
}
.md-typeset .md-button.md-button--primary {
background-color: --md-primary-fg-color;
border-color: --md-primary-fg-color;
color: white;
padding: 0.75em 2em;
font-size: 1rem;
border-radius: .75rem;
}
.md-button.md-button--primary:hover {
opacity: 1.0;
text-decoration: none;
}
.md-header__button.md-logo {
color: white;
padding: 0;
margin: 0;
margin-bottom: 0.4rem;
}
.md-header__button.md-logo img {
height: 1.8rem;
}
.md-search__form {
border-radius: 5px;
}
.icon-discord {
width: 30px;
height: 30px;
}
.icon-github {
width: 25px;
height: 25px;
}
.icon-github path,
.icon-discord path {
fill: white;
}
.md-logo {
height: 40px;
}
.md-logo:hover {
text-decoration: none;
opacity: 1.0;
}
.md-header {
padding: 20px;
padding-top: 15px;
padding-bottom: 15px;
}
.md-header__inner {
display: flex;
justify-content: space-between;
align-items: center;
}
.md-header__links {
display: flex;
gap: 1rem;
align-items: center;
}
.md-nav {
line-height: 2;
}
.md-nav__link {
margin-top: 0;
}
.md-nav__link:hover,
.md-nav__link:focus {
text-decoration: underline;
}
.md-header__option {
display: flex;
align-items: center;
gap: 1rem;
}
.md-search {
display: flex;
align-items: center;
}
.md-header__topic {
display: flex;
gap: 1.25rem;
align-items: center;
}
.md-custom-header,
.md-header__title {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 18px;
}
.md-custom-header-right {
display: flex;
align-items: center;
gap: 1rem;
margin-left: 20px;
margin-right: 20px;
}
.md-custom-header-right .md-nav__link:hover {
color: white;
}
.feature-list {
font-size: 1rem;
margin: auto;
margin-bottom: 30px;
max-width: 600px;
}
@media screen and (max-width: 60em) {
.feature-list {
font-size: 0.8rem;
}
.md-custom-header-right {
margin-left: 0;
margin-right: 0;
}
.icon-discord,
.icon-github {
margin-right: 1rem;
}
.icon-discord {
width: 25px;
height: 25px;
}
.icon-github {
width: 20px;
height: 20px;
}
.md-header__button {
margin-left: 0;
}
[dir=ltr] .md-header__title {
margin-left: 0;
margin-right: 0;
}
}
+504
View File
@@ -0,0 +1,504 @@
<!DOCTYPE html>
<html>
<head>
<title>Spark &#8226; Examples</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
:root {
--panel-width: 300px;
--border-style: 4px solid #ab2f3b;
}
body, html {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
margin: 0;
padding: 0;
font-family: "Inter", sans-serif;
font-weight: 300;
}
.mobile-panel {
display: none;
}
.panel {
position: fixed;
z-index: 100;
left: 0px;
width: var(--panel-width);
height: 100%;
border-right: var(--border-style);
display: flex;
flex-direction: column;
transition: 0s 0s height;
background: #D43E4C;
}
.header {
display: flex;
height: 4rem;
align-items: center;
justify-content: center;
padding: 0 1rem;
flex-shrink: 0;
}
.header .logo {
height: 2rem;
padding-bottom: .8rem;
}
.header .title {
color: white;
font-size: 1.0rem;
text-transform: uppercase;
letter-spacing: 0.04rem;
}
.header a, .mobile-header a {
display: flex;
align-items: center;
text-decoration: none;
width: 90px;
}
.header a .logo, .mobile-header a .logo {
height: 1.25rem;
display: block;
}
.examples-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.content {
margin-left: var(--panel-width);
width: calc(100% - var(--panel-width));
height: 100%;
position: fixed;
top: 0;
}
.content iframe {
width: 100%;
height: 100%;
border: none;
}
.action-buttons {
position: fixed;
bottom: 16px;
right: 16px;
display: flex;
flex-direction: row;
gap: 12px;
z-index: 999;
}
.action-button {
padding: 12px;
border-radius: 50%;
background-color: #D43E4C;
opacity: .9;
box-shadow: 0 0 4px rgba(0, 0, 0, .15);
text-decoration: none;
transition: opacity 0.2s ease;
}
.action-button:hover {
opacity: 1;
}
.example-link {
display: block;
font-size: 1rem;
padding: 10px 15px;
text-decoration: none;
color: #ffffff;
}
.example-link:hover {
background-color: rgba(0, 0, 0, 0.15);
}
.example-link.active {
background-color: rgba(0, 0, 0, 0.15);
border-left: 4px solid #ffffff;
}
.mobile-header {
display: none;
}
@media screen and (max-width: 768px) {
.panel {
display: none !important;
}
.mobile-header {
display: flex !important;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #D43E4C;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
height: 2rem;
}
.mobile-header .logo-section {
display: flex;
flex-direction: row;
color: white;
}
.header a, .mobile-header a {
width: 56px;
padding-bottom: 15px;
}
.content {
margin: 0 !important;
padding-top: 4rem !important;
width: 100% !important;
height: calc(100vh - 4rem) !important;
}
.mobile-panel {
display: flex;
position: fixed;
flex-direction: column;
top: 4rem;
right: -100%;
left: auto;
width: 70%;
height: calc(100vh - 4rem);
transform: none;
transition: right 0.3s ease-in-out;
z-index: 1001;
background: #D43E4C;
border-left: var(--border-style);
overflow-y: auto;
}
.mobile-panel.active {
right: 0;
}
.mobile-panel .example-link.active {
border-left: none;
border-right: 4px solid #ffffff;
}
.mobile-panel .header {
display: none;
}
.overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.overlay.active {
display: block;
}
}
</style>
<script>
const exampleMap = {
'hello-world': '/examples/hello-world/index.html',
'envmap': '/examples/envmap/index.html',
'interactivity': '/examples/interactivity/index.html',
'multiple-splats': '/examples/multiple-splats/index.html',
'multiple-viewpoints': '/examples/multiple-viewpoints/index.html',
'procedural-splats': '/examples/procedural-splats/index.html',
'raycasting': '/examples/raycasting/index.html',
'dynamic-lighting': '/examples/dynamic-lighting/index.html',
'particle-animation': '/examples/particle-animation/index.html',
'particle-simulation': '/examples/particle-simulation/index.html',
'splat-painter': '/examples/splat-painter/index.html',
'splat-reveal-effects': '/examples/splat-reveal-effects/index.html',
'splat-shader-effects': '/examples/splat-shader-effects/index.html',
'splat-transitions': '/examples/splat-transitions/index.html',
'stochastic': '/examples/stochastic/index.html',
'sogs': '/examples/sogs/index.html',
'webxr': '/examples/webxr/index.html',
'glsl': '/examples/glsl/index.html',
'debug-color': '/examples/debug-color/index.html',
'depth-of-field': '/examples/depth-of-field/index.html',
'splat-texture': '/examples/splat-texture/index.html',
'editor': '/examples/editor/index.html'
};
function getExampleFromHash() {
const hash = window.location.hash.substring(1);
return hash || 'hello-world';
}
function setActiveExample(exampleKey) {
const path = exampleMap[exampleKey];
if (!path) return;
const iframe = document.querySelector('.example-frame');
const codeButton = document.querySelector('.code-button');
const fullPageButton = document.querySelector('.fullpage-button');
iframe.src = path;
if (codeButton) {
codeButton.href = `https://github.com/sparkjsdev/spark/blob/main${path}`;
}
if (fullPageButton) {
fullPageButton.href = path;
}
document.querySelectorAll('.example-link').forEach(link => {
link.classList.remove('active');
if (link.getAttribute('data-example') === exampleKey) {
link.classList.add('active');
}
});
if (window.location.hash.substring(1) !== exampleKey) {
history.replaceState(null, null, `#${exampleKey}`);
}
}
function loadExamples() {
const panel = document.querySelector('.panel');
const iframe = document.querySelector('.example-frame');
const actionButtons = document.createElement('div');
actionButtons.classList.add('action-buttons');
const fullPageButton = document.createElement('a');
fullPageButton.classList.add('action-button', 'fullpage-button');
fullPageButton.target = '_blank';
fullPageButton.title = 'Open this example in full page';
fullPageButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" style="display:block;">
<path fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6m10 0l-9 9m3-9h6v6"/>
</svg>
`;
const codeButton = document.createElement('a');
codeButton.classList.add('action-button', 'code-button');
codeButton.target = '_blank';
codeButton.title = 'View source code for this example on GitHub';
codeButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="display:block;">
<path fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 8l-4 4l4 4m6-8l4 4l-4 4"/>
</svg>
`;
actionButtons.appendChild(fullPageButton);
actionButtons.appendChild(codeButton);
document.body.appendChild(actionButtons);
const initialExample = getExampleFromHash();
setActiveExample(initialExample);
panel.querySelectorAll('.example-link').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const exampleKey = link.getAttribute('data-example');
setActiveExample(exampleKey);
});
});
window.addEventListener('hashchange', () => {
const exampleKey = getExampleFromHash();
setActiveExample(exampleKey);
});
}
function setupMobileMenu() {
const hamburger = document.querySelector('.hamburger');
const panel = document.querySelector('.panel');
const overlay = document.querySelector('.overlay');
const iframe = document.querySelector('.example-frame');
const codeButton = document.querySelector('.code-button');
const mobilePanel = document.createElement('div');
mobilePanel.classList.add('mobile-panel');
const copyLinks = () => {
mobilePanel.innerHTML = '';
const links = panel.querySelectorAll('.example-link');
links.forEach(link => {
const newLink = link.cloneNode(true);
if (link.classList.contains('active')) {
newLink.classList.add('active');
}
mobilePanel.appendChild(newLink);
});
};
copyLinks();
const observer = new MutationObserver(copyLinks);
observer.observe(panel, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
document.body.appendChild(mobilePanel);
function closeMenu() {
mobilePanel.classList.remove('active');
overlay.classList.remove('active');
}
function openMenu() {
mobilePanel.classList.add('active');
overlay.classList.add('active');
}
hamburger.addEventListener('click', (e) => {
e.stopPropagation();
if (mobilePanel.classList.contains('active')) {
closeMenu();
} else {
openMenu();
}
});
overlay.addEventListener('click', closeMenu);
mobilePanel.addEventListener('click', (e) => {
const link = e.target.closest('.example-link');
if (link) {
e.preventDefault();
const exampleKey = link.getAttribute('data-example');
setActiveExample(exampleKey);
closeMenu();
}
});
}
document.addEventListener('DOMContentLoaded', () => {
loadExamples();
setupMobileMenu();
});
</script>
</head>
<body>
<div class="overlay"></div>
<div class="mobile-header">
<div class="logo-section">
<a class="logo" href="/">
<svg>
<use xlink:href="#spark-logo" />
</svg>
</a>
<!-- <span class="title">EXAMPLES</span> -->
</div>
<div class="hamburger">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12H21" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M3 6H21" stroke="white" stroke-width="2" stroke-linecap="round"/>
<path d="M3 18H21" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
</div>
<div class="panel">
<div class="header">
<a class="logo" href="/">
<svg style="width: 100%; height: 100%;">
<use xlink:href="#spark-logo" style="position: absolute; width: 100%; height: 100%;"/>
</svg>
</a>
<span class="title">EXAMPLES</span>
</div>
<div class="examples-container">
<a href="#hello-world" data-example="hello-world" class="example-link">Hello World</a>
<a href="#envmap" data-example="envmap" class="example-link">Environment Map</a>
<a href="#interactivity" data-example="interactivity" class="example-link">Interactivity</a>
<a href="#multiple-splats" data-example="multiple-splats" class="example-link">Multiple Splats</a>
<a href="#multiple-viewpoints" data-example="multiple-viewpoints" class="example-link">Multiple Viewpoints</a>
<a href="#procedural-splats" data-example="procedural-splats" class="example-link">Procedural Splats</a>
<a href="#raycasting" data-example="raycasting" class="example-link">Raycasting</a>
<a href="#dynamic-lighting" data-example="dynamic-lighting" class="example-link">Dynamic Lighting</a>
<a href="#particle-animation" data-example="particle-animation" class="example-link">Particle Animation</a>
<a href="#particle-simulation" data-example="particle-simulation" class="example-link">Particle Simulation</a>
<a href="#splat-painter" data-example="splat-painter" class="example-link">Splat Painter</a>
<a href="#splat-reveal-effects" data-example="splat-reveal-effects" class="example-link">Splat Reveal Effects</a>
<a href="#splat-shader-effects" data-example="splat-shader-effects" class="example-link">Splat Shader Effects</a>
<a href="#splat-transitions" data-example="splat-transitions" class="example-link">Splat Transitions</a>
<a href="#stochastic" data-example="stochastic" class="example-link">Stochastic splat sorting</a>
<a href="#sogs" data-example="sogs" class="example-link">SOGS Compression</a>
<a href="#webxr" data-example="webxr" class="example-link">WebXR</a>
<a href="#glsl" data-example="glsl" class="example-link">GLSL Shaders</a>
<a href="#debug-color" data-example="debug-color" class="example-link">Debug Coloring</a>
<a href="#depth-of-field" data-example="depth-of-field" class="example-link">Depth of Field</a>
<a href="#splat-texture" data-example="splat-texture" class="example-link">Splat Texture</a>
<a href="#editor" data-example="editor" class="example-link">Editor</a>
</div>
</div>
<div class="content">
<iframe class="example-frame" src=""></iframe>
</div>
<svg style="display: none" xmlns="http://www.w3.org/2000/svg">
<defs>
<symbol id="spark-logo" viewBox="0 0 530 250" fill="none">
<path
d="M40.3564 215.311C45.3883 215.311 49.1623 214.583 51.6782 213.126C54.1942 211.67 55.4521 209.286 55.4521 205.976C55.4521 203.725 54.8563 201.937 53.6645 200.613C52.4727 199.289 50.354 197.964 47.3084 196.64L26.2539 186.907C18.4412 183.332 12.4161 178.565 8.17872 172.606C3.94132 166.647 1.82263 157.974 1.82263 146.586C1.82263 132.02 5.59656 121.162 13.1444 114.011C20.8247 106.728 33.4707 103.02 51.0823 102.888C58.3653 102.888 64.9863 103.219 70.9451 103.881C76.904 104.543 81.671 105.271 85.2463 106.066C88.8216 106.728 90.6093 107.059 90.6093 107.059L86.6367 139.038C86.6367 139.038 84.9815 138.84 81.671 138.442C78.493 138.045 74.5204 137.714 69.7533 137.449C64.9863 137.052 60.3516 136.853 55.8494 136.853C51.0823 136.853 47.4408 137.582 44.9249 139.038C42.5413 140.362 41.3496 142.944 41.3496 146.785C41.3496 148.903 42.2765 150.691 44.1304 152.148C45.9842 153.472 48.5664 154.862 51.8768 156.319L68.7602 163.668C77.7647 167.641 84.3194 172.606 88.4244 178.565C92.5293 184.392 94.5818 192.734 94.5818 203.592C94.5818 218.291 90.7417 229.612 83.0614 237.558C75.3811 245.503 62.7352 249.541 45.1235 249.674C36.3839 249.674 28.2401 249.078 20.6923 247.886C13.1444 246.827 6.45728 245.569 0.630859 244.112L4.60342 212.133C4.60342 212.133 6.19244 212.398 9.37048 212.928C12.6809 213.457 17.0508 213.987 22.4799 214.517C27.9091 215.046 33.8679 215.311 40.3564 215.311Z"
fill="black"
id="path264"
style="fill:#ffffff" />
<path
d="M105.441 247.688L105.283 104.676H151.721C163.639 104.676 173.504 106.331 181.317 109.641C189.13 112.819 194.956 118.248 198.796 125.929C202.636 133.609 204.556 144.07 204.556 157.312C204.556 175.586 200.187 188.761 191.447 196.839C182.707 204.916 169.466 208.955 151.721 208.955H144.174V247.688H105.441ZM144.174 180.551C150.53 180.551 155.231 178.83 158.276 175.387C161.454 171.944 163.043 165.787 163.043 156.915C163.043 150.161 162.447 145.063 161.256 141.62C160.196 138.177 158.276 135.86 155.495 134.668C152.847 133.344 149.073 132.682 144.174 132.682V180.551Z"
fill="black"
id="path266"
style="fill:#ffffff" />
<path
d="M240.887 228.421L237.113 247.688H195.6L226.418 114.017L183.707 76.5293L265.17 96.9426L244.641 123.649L283.517 121.162L310.804 247.688H269.688L265.914 228.421H240.887ZM244.859 197.633H261.743L253.4 145.593L244.859 197.633Z"
fill="black"
id="path268"
style="fill:#ffffff" />
<path
d="M359.88 104.676C371.533 104.676 381.266 106.198 389.079 109.244C396.891 112.157 402.718 117.123 406.558 124.141C410.53 131.027 412.517 140.495 412.517 152.545C412.517 162.609 410.994 170.885 407.948 177.373C404.903 183.729 400.268 188.828 394.044 192.668L418.277 247.688H375.175L359.483 201.209H353.127V247.688H314.395V125.135L341.069 131.557L318.133 104.676H359.88ZM353.127 174.593C359.218 174.593 363.72 173.07 366.634 170.024C369.547 166.978 371.003 161.616 371.003 153.935C371.003 147.844 370.474 143.342 369.414 140.429C368.355 137.383 366.501 135.33 363.853 134.271C361.337 133.212 357.762 132.682 353.127 132.682V174.593Z"
fill="black"
id="path270"
style="fill:#ffffff" />
<path
d="M428.21 104.676H466.942V162.079L488.195 104.676H529.907L503.291 173.997L528.517 247.688H486.805L466.942 187.106V247.688H428.21V104.676Z"
fill="black"
id="path272"
style="fill:#ffffff" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M309.449 81.6439L327.348 19.5389L375.544 0.839203L348.827 48.8799L309.449 81.6439Z"
fill="black"
id="path274"
style="fill:#ffffff" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M268.575 30.8712L282.27 79.3501L253.733 52.8261L268.575 30.8712Z"
fill="black"
id="path276"
style="fill:#ffffff" />
</symbol>
</defs>
<use href="#spark-logo"/>
</svg>
</body>
</html>
+146
View File
@@ -0,0 +1,146 @@
{
"arrow.glb": {
"url": "https://sparkjs.dev/assets/models/arrow.glb",
"directory": "models"
},
"branzino-amarin.spz": {
"url": "https://sparkjs.dev/assets/splats/food/branzino-amarin.spz",
"directory": "splats/food"
},
"burger-from-amboy.spz": {
"url": "https://sparkjs.dev/assets/splats/food/burger-from-amboy.spz",
"directory": "splats/food"
},
"butterfly.png": {
"url": "https://sparkjs.dev/assets/images/butterfly.png",
"directory": "images"
},
"butterfly.spz": {
"url": "https://sparkjs.dev/assets/splats/butterfly.spz",
"directory": "splats"
},
"butterfly-ai.spz": {
"url": "https://sparkjs.dev/assets/splats/butterfly-ai.spz",
"directory": "splats"
},
"butterfly-wings-closed.spz": {
"url": "https://sparkjs.dev/assets/splats/butterfly-wings-closed.spz",
"directory": "splats"
},
"cat.spz": {
"url": "https://sparkjs.dev/assets/splats/cat.spz",
"directory": "splats"
},
"clams-and-caviar-by-ikoyi.spz": {
"url": "https://sparkjs.dev/assets/splats/food/clams-and-caviar-by-ikoyi.spz",
"directory": "splats/food"
},
"coral-caviar.spz": {
"url": "https://sparkjs.dev/assets/splats/food/coral-caviar.spz",
"directory": "splats/food"
},
"dali-env.glb": {
"url": "https://sparkjs.dev/assets/models/dali-env.glb",
"directory": "models"
},
"dali-table.glb": {
"url": "https://sparkjs.dev/assets/models/dali-table.glb",
"directory": "models"
},
"dessert.spz": {
"url": "https://sparkjs.dev/assets/splats/dessert.spz",
"directory": "splats"
},
"double-double-from-InNOut.spz": {
"url": "https://sparkjs.dev/assets/splats/food/double-Double-from-InNOut.spz",
"directory": "splats/food"
},
"fireplace.spz": {
"url": "https://sparkjs.dev/assets/splats/fireplace.spz",
"directory": "splats"
},
"forge.spz": {
"url": "https://sparkjs.dev/assets/splats/forge.spz",
"directory": "splats"
},
"furry-logo-pedestal.spz": {
"url": "https://sparkjs.dev/assets/splats/furry-logo-pedestal.spz",
"directory": "splats"
},
"greyscale-bedroom.spz": {
"url": "https://storage.googleapis.com/forge-dev-public/marble-scenes/greyscale-room.spz",
"directory": "splats"
},
"gyro.spz": {
"url": "https://sparkjs.dev/assets/splats/food/gyro.spz",
"directory": "splats/food"
},
"heart.png": {
"url": "https://sparkjs.dev/assets/images/heart.png",
"directory": "images"
},
"iberico-sandwich-by-reserve.spz": {
"url": "https://sparkjs.dev/assets/splats/food/iberico-sandwich-by-reserve.spz",
"directory": "splats/food"
},
"pad-thai.spz": {
"url": "https://sparkjs.dev/assets/splats/food/pad-thai.spz",
"directory": "splats/food"
},
"penguin.spz": {
"url": "https://sparkjs.dev/assets/splats/penguin.spz",
"directory": "splats"
},
"painted-bedroom.spz": {
"url": "https://storage.googleapis.com/forge-dev-public/painted_bedroom.spz",
"directory": "splats"
},
"primerib-tamos.spz": {
"url": "https://sparkjs.dev/assets/splats/food/primerib-tamos.spz",
"directory": "splats/food"
},
"robot-head.spz": {
"url": "https://sparkjs.dev/assets/splats/robot-head.spz",
"directory": "splats"
},
"rubberduck.glb": {
"url": "https://sparkjs.dev/assets/models/rubberduck.glb",
"directory": "models"
},
"sky.jpeg": {
"url": "https://sparkjs.dev/assets/images/sky.jpeg",
"directory": "images"
},
"snow-street.spz": {
"url": "https://sparkjs.dev/assets/splats/snow-street.spz",
"directory": "splats"
},
"star.png": {
"url": "https://sparkjs.dev/assets/images/star.png",
"directory": "images"
},
"steaksandwich-mels.spz": {
"url": "https://sparkjs.dev/assets/splats/food/steaksandwich-mels.spz",
"directory": "splats/food"
},
"sutro.zip": {
"url": "https://sparkjs.dev/assets/splats/sutro.zip",
"directory": "splats"
},
"table.glb": {
"url": "https://sparkjs.dev/assets/models/table.glb",
"directory": "models"
},
"tomahawk-niku.spz": {
"url": "https://sparkjs.dev/assets/splats/food/tomahawk-niku.spz",
"directory": "splats/food"
},
"valley.spz": {
"url": "https://sparkjs.dev/assets/splats/valley.spz",
"directory": "splats"
},
"woobles.spz": {
"url": "https://sparkjs.dev/assets/splats/woobles.spz",
"directory": "splats"
}
}
+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Debug Coloring</title>
<style>
body {
margin: 0;
}
canvas {
touch-action: none;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SplatMesh, PackedSplats, dyno, modifiers } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
let splatURL = await getAssetFileURL("butterfly.spz");
const butterfly = new SplatMesh({ url: splatURL });
butterfly.scale.setScalar(0.5);
butterfly.quaternion.set(1, 0, 0, 0);
butterfly.position.set(-0.5, 0, -1.5);
modifiers.setWorldNormalColor(butterfly);
scene.add(butterfly);
splatURL = await getAssetFileURL("butterfly-ai.spz");
const butterfly2 = new SplatMesh({ url: splatURL });
butterfly2.quaternion.set(1, 0, 0, 0);
butterfly2.position.set(0.5, 0, -1.5);
modifiers.setDepthColor(butterfly2, 1, 2, true);
scene.add(butterfly2);
let lastTime;
renderer.setAnimationLoop(function animate(time) {
const deltaTime = time - (lastTime || time);
lastTime = time;
butterfly.rotation.y += deltaTime / 2500;
butterfly2.rotation.x += deltaTime / 2500;
renderer.render(scene, camera);
});
</script>
</body>
</html>
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Depth of Field</title>
<style>
body {
margin: 0;
}
canvas {
touch-action: none;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SparkRenderer, SplatMesh, SparkControls } from "@sparkjsdev/spark";
import GUI from "lil-gui";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
const spark = new SparkRenderer({
renderer,
apertureAngle: 0.02,
focalDistance: 5.0,
});
scene.add(spark);
const apertureSize = {
apertureSize: 0.1,
};
function updateApertureAngle() {
if (spark.focalDistance > 0) {
spark.apertureAngle = 2 * Math.atan(0.5 * apertureSize.apertureSize / spark.focalDistance);
} else {
spark.apertureAngle = 0.0;
}
}
updateApertureAngle();
const gui = new GUI({ title: "DoF settings" });
gui.add(spark, "focalDistance", 0, 15, 0.01).name("Focal plane dist")
.onChange(updateApertureAngle);
gui.add(apertureSize, "apertureSize", 0, 0.4, 0.01).name("Aperture size")
.onChange(updateApertureAngle);
const splatURL = await getAssetFileURL("valley.spz");
const background = new SplatMesh({ url: splatURL });
background.quaternion.set(1, 0, 0, 0);
background.scale.setScalar(0.5);
scene.add(background);
const controls = new SparkControls({ canvas: renderer.domElement });
renderer.setAnimationLoop(function animate(time) {
controls.update(camera);
renderer.render(scene, camera);
});
</script>
</body>
</html>
@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • VFX Dynamic Lighting</title>
<style>
html,
body {
margin: 0;
height: 100%;
background-color: black;
}
#canvas {
position: absolute;
width: 100%;
height: 100%;
touch-action: none;
}
header {
position: absolute;
z-index: 10;
margin: 1rem 2rem;
}
label {
font-family: sans-serif;
font-size: 20px;
color: white;
cursor: pointer;
margin-right: 2rem;
margin-bottom: 0.5rem;
background-color: black;
border-radius: 10px;
}
header {
width: 100%;
padding: 0;
margin-left: 0;
padding-top: 20px;
text-align: center;
}
@media screen and (max-width: 60em) {
label {
font-size: 18px;
}
header {
margin-left: 1.2rem;
}
}
</style>
</head>
<body>
<header>
<label><input type="checkbox" id="debug" autocomplete="off">Debug Light SDFs</label>
<label><input type="checkbox" id="daynight" checked="checked" autocomplete="off"> Enable Lighting</label>
</header>
<canvas id="canvas"></canvas>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import {
SplatMesh,
SplatEdit,
SparkRenderer,
SplatEditSdf,
SplatEditSdfType,
SplatEditRgbaBlendMode,
FpsMovement,
PointerControls,
} from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const canvas = document.getElementById("canvas");
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 0.3, -2.5);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// Create a SparkRenderer and add it to the scene to render all the Gsplats.
const spark = new SparkRenderer({ renderer });
scene.add(spark);
const splatURL = await getAssetFileURL("fireplace.spz");
const fireplace = new SplatMesh({ url: splatURL });
fireplace.quaternion.set(1, 0, 0, 0);
fireplace.position.set(0, -1, -10);
scene.add(fireplace);
const fpsMovement = new FpsMovement({ moveSpeed: 2 });
const pointerControls = new PointerControls({ canvas });
let night = true;
const lights = [];
// Debug SDF spheres
const helpers = [];
function createLight(scene, lightingLayer, position, color, radius, opacity) {
const light = new SplatEditSdf({
type: SplatEditSdfType.SPHERE,
color: color,
radius: radius,
opacity: opacity,
});
light.position.copy(position);
// create a wireframe helper
const helper = new THREE.Mesh(
new THREE.SphereGeometry(radius, 16, 16),
new THREE.MeshBasicMaterial({ wireframe: true, color: color }),
);
helper.position.copy(light.position);
helper.visible = false;
scene.add(helper);
helpers.push(helper);
lightingLayer.add(light);
lights.push(light);
}
// Create lighting layer
const emberLayer = new SplatEdit({
rgbaBlendMode: SplatEditRgbaBlendMode.ADD_RGBA,
sdfSmooth: 0.1,
softEdge: 0.8,
});
scene.add(emberLayer);
const lightingLayer = new SplatEdit({
rgbaBlendMode: SplatEditRgbaBlendMode.ADD_RGBA,
sdfSmooth: 0.1,
softEdge: 1.4,
});
scene.add(lightingLayer);
const ambientLayer = new SplatEdit({
rgbaBlendMode: SplatEditRgbaBlendMode.DARKEN,
sdfSmooth: 0.1,
softEdge: 0.05,
});
scene.add(ambientLayer);
// glowing embers at the base of the fire
createLight(
scene,
emberLayer,
new THREE.Vector3(0.5, -1.0, -10.5),
new THREE.Color(1, 0.6, 0.4),
0.75,
1,
);
// light in the fireplace
createLight(
scene,
lightingLayer,
new THREE.Vector3(0.3, -1.1, -10.6),
new THREE.Color(1, 0.95, 0.2),
1.6,
0,
);
// ambient light throughout the full room
createLight(
scene,
ambientLayer,
new THREE.Vector3(0, 1, -11),
new THREE.Color(1, 0.8, 0.6),
6,
0.8,
);
console.log(helpers);
let lastTime;
renderer.setAnimationLoop(time => {
const timeSeconds = time * 0.0005;
const deltaTime = timeSeconds - (lastTime ?? timeSeconds);
lastTime = timeSeconds;
// fpsMovement.update(deltaTime, camera);
pointerControls.update(deltaTime, camera);
camera.position.z = -2.5 - Math.sin(timeSeconds * 0.1) * 2;
renderer.render(scene, camera);
// Create flickering fire effect
const baseHue = 0.04; // Orange-red base hue
const fireHue = 0.03; // Yellow base hue
const hueVariation = 0.03; // Slight variation in hue
// Add some randomness to the flicker
const randomFlicker = Math.sin(timeSeconds * 4) * 0.5 + 0.5;
// we'll combine these to make a more natural flickering effect
const mediumFlicker = Math.sin(timeSeconds * 13) * 0.1 + 0.1; // Medium flicker
const fastFlicker = Math.sin(timeSeconds * 20) * 0.1 + 0.1; // Fast flicker
const slowFlicker = Math.sin(timeSeconds * 6) * 0.04 + 0.5; // Slow base flicker
// Combine the flickers
const combinedFlicker = (slowFlicker + mediumFlicker + fastFlicker) / 3;
for (let i = 0; i < lights.length - 1; i++) {
const h = baseHue + combinedFlicker * hueVariation; // Slightly varying orange-red hue
const s = 0.5 + randomFlicker * 0.3; // High saturation with slight variation
const l = 0.5 + combinedFlicker * 0.2; // Varying brightness
lights[i].color.setHSL(h, s, l);
lights[i].visible = night;
}
lights[lights.length - 1].visible = night;
lights[lights.length - 1].color.setHSL(baseHue, 0.5, combinedFlicker);
});
document.getElementById('debug').addEventListener('change', ev => {
const visible = ev.target.checked;
for (let helper of helpers) {
helper.visible = visible;
}
});
document.getElementById('daynight').addEventListener('change', ev => {
night = !night;
});
</script>
</body>
</html>
+817
View File
@@ -0,0 +1,817 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Spark • Editor</title>
<style>
body {
margin: 0;
}
#canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
outline: none; /* Remove default focus outline */
touch-action: none;
}
#main-gui {
position: absolute;
top: 5px;
right: 5px;
}
#second-gui {
position: absolute;
top: 5px;
left: 5px;
}
#progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background-color: rgba(0, 0, 0, 0.1);
z-index: 1000;
display: none;
}
#progress-fill {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #45a049);
width: 0%;
transition: width 0.3s ease;
}
</style>
</head>
<body>
<div id="progress-bar">
<div id="progress-fill"></div>
</div>
<script src="/examples/js/vendor/stats.js/build/stats.min.js"></script>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"@sparkjsdev/spark": "/dist/spark.module.js",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js"
}
}
</script>
<canvas id="canvas" tabindex="0"></canvas>
<div id="main-gui"></div>
<div id="second-gui"></div>
<input id="file-input" type="file" accept=".ply,.spz,.splat,.ksplat,.zip" multiple="true" style="display: none;" />
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GUI } from "lil-gui";
import { constructGrid, SparkControls, SparkRenderer, SplatMesh, textSplats, dyno, transcodeSpz, isMobile, isPcSogs, LN_SCALE_MIN, LN_SCALE_MAX } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.01, 1000);
camera.position.set(0, 0, 1);
const canvas = document.getElementById("canvas");
const renderer = new THREE.WebGLRenderer({ canvas });
const spark = new SparkRenderer({ renderer });
scene.add(spark);
function handleResize() {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
handleResize();
window.addEventListener("resize", handleResize);
const frame = new THREE.Group();
frame.quaternion.set(1, 0, 0, 0);
scene.add(frame);
// Keep track of original file bytes for each loaded splat mesh
const inputs = [];
const grid = new SplatMesh({
constructSplats: (splats) => constructGrid({
splats,
extents: new THREE.Box3(new THREE.Vector3(-10, -10, -10), new THREE.Vector3(10, 10, 10)),
}),
});
grid.opacity = 0;
scene.add(grid);
const stats = new Stats();
document.body.appendChild(stats.dom);
stats.dom.style.display = "none";
const gui = new GUI({
title: "Settings",
container: document.getElementById("main-gui")
});
const secondGui = new GUI({
title: "Splats",
container: document.getElementById("second-gui")
}).close();
const controls = new SparkControls({ canvas });
// Setup mouse controls to orbit the camera around
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enabled = false;
orbitControls.target.set(0, 0, 0);
orbitControls.minDistance = 0.1;
orbitControls.maxDistance = 10;
const fileInput = document.querySelector("#file-input");
fileInput.onchange = (event) => {
loadFiles([...event.target.files]);
};
const guiOptions = {
highDevicePixel: !isMobile(),
stats: false,
resetOnLoad: true,
loadOffset: 0,
openCv: true,
autoRotate: true,
orbit: false,
reversePointerDir: false,
reversePointerSlide: false,
backgroundColor: "#000000",
viewBoundingBox: false,
openFiles: () => {
fileInput.click();
},
loadFromText: "",
loadFromTextAction: () => {
if (guiOptions.loadFromText.trim()) {
const urls = parseURLsFromText(guiOptions.loadFromText);
if (urls.length > 0) {
loadFiles(urls);
guiOptions.loadFromText = ""; // Clear after loading
} else {
alert("No valid URLs found in text. URLs must start with http:// or https:// and end with .ply, .spz, .splat, .ksplat, .json, or .zip");
}
}
},
resetPose: () => {
camera.position.set(0, 0, 1);
camera.quaternion.set(0, 0, 0, 1);
camera.fov = 75;
resetFrameQuaternion();
camera.updateProjectionMatrix();
},
};
function resetFrameQuaternion() {
if (guiOptions.openCv) {
frame.quaternion.set(1, 0, 0, 0);
} else {
frame.quaternion.set(0, 0, 0, 1);
}
}
// Parse URL query parameters for URLs to load
function parseURLsFromQuery() {
const urlParams = new URLSearchParams(window.location.search);
const urls = [];
// Get all "url" parameters
for (const [key, value] of urlParams.entries()) {
if (key === "url") {
urls.push(value);
}
}
return urls;
}
// Parse and apply camera parameters from URL
function applyCameraFromQuery() {
const urlParams = new URLSearchParams(window.location.search);
// Position parameters
const x = urlParams.get('x');
const y = urlParams.get('y');
const z = urlParams.get('z');
if (x !== null || y !== null || z !== null) {
camera.position.set(
x !== null ? parseFloat(x) : camera.position.x,
y !== null ? parseFloat(y) : camera.position.y,
z !== null ? parseFloat(z) : camera.position.z
);
}
// Quaternion parameters
const qx = urlParams.get('qx');
const qy = urlParams.get('qy');
const qz = urlParams.get('qz');
const qw = urlParams.get('qw');
if (qx !== null || qy !== null || qz !== null || qw !== null) {
camera.quaternion.set(
qx !== null ? parseFloat(qx) : camera.quaternion.x,
qy !== null ? parseFloat(qy) : camera.quaternion.y,
qz !== null ? parseFloat(qz) : camera.quaternion.z,
qw !== null ? parseFloat(qw) : camera.quaternion.w
).normalize();
}
}
// Apply camera parameters from URL
guiOptions.resetPose();
applyCameraFromQuery();
const cameraFolder = gui.addFolder("Camera");
const cameraPose = cameraFolder.addFolder("Camera Pose");
cameraPose.add(camera.position, "x", -10, 10, 0.01).name("X").listen();
cameraPose.add(camera.position, "y", -10, 10, 0.01).name("Y").listen();
cameraPose.add(camera.position, "z", -10, 10, 0.01).name("Z").listen();
const rotX = cameraPose.add(camera.rotation, "x", -Math.PI, Math.PI, 0.01).name("RotateX").listen();
const rotY = cameraPose.add(camera.rotation, "y", -Math.PI, Math.PI, 0.01).name("RotateY").listen();
const rotZ = cameraPose.add(camera.rotation, "z", -Math.PI, Math.PI, 0.01).name("RotateZ").listen();
cameraPose.add(camera, "fov", 1, 179, 1).name("Fov Y degrees").listen().onChange((value) => {
camera.updateProjectionMatrix();
});
cameraPose.close();
function touch() {
spark.needsUpdate = true;
}
// Progress bar functions
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
function showProgress() {
progressBar.style.display = "block";
progressFill.style.width = "0%";
}
function updateProgress(progress) {
progressFill.style.width = `${Math.min(100, Math.max(0, progress * 100))}%`;
}
function hideProgress() {
progressBar.style.display = "none";
}
// Calculate progress when total size is unknown
function calculateUnknownProgress(loadedBytes) {
const midpointMB = 10 * 1024 * 1024; // 10 MB in bytes
return loadedBytes / (loadedBytes + midpointMB);
}
async function fetchWithProgress(url) {
try {
const response = await fetch(url, {
mode: "cors",
cache: "default",
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const contentLength = response.headers.get('content-length');
const total = contentLength ? parseInt(contentLength, 10) : null;
const reader = response.body.getReader();
const chunks = [];
let loadedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loadedBytes += value.length;
// Calculate progress
let progress;
if (total) {
progress = loadedBytes / total;
} else {
progress = calculateUnknownProgress(loadedBytes);
}
updateProgress(progress);
}
// Combine all chunks into a single Uint8Array
const allChunks = new Uint8Array(loadedBytes);
let offset = 0;
for (const chunk of chunks) {
allChunks.set(chunk, offset);
offset += chunk.length;
}
return allChunks.buffer;
} catch (error) {
// Hide progress bar on error
hideProgress();
// Detect CORS issues
if (error instanceof TypeError && (
error.message.includes('Failed to fetch') ||
error.message.includes('Network request failed') ||
error.message.includes('CORS') ||
error.message.includes('cross-origin')
)) {
alert(`CORS Error: Cannot load file from ${url}\n\nThe server doesn't allow cross-origin requests. The file needs to be:\n• Served from the same domain, or\n• The server must include CORS headers like:\n Access-Control-Allow-Origin: *`);
} else if (error.message.includes('HTTP error! status:')) {
const status = error.message.match(/status: (\d+)/)?.[1];
alert(`HTTP Error ${status}: Cannot load file from ${url}\n\n${error.message}`);
} else {
alert(`Error loading file from ${url}:\n\n${error.message}`);
}
throw error; // Re-throw so loadFiles can handle it
}
}
async function loadFiles(splatFiles) {
if (guiOptions.resetOnLoad) {
const toRemove = frame.children.filter((child) => child instanceof SplatMesh || child instanceof THREE.Box3Helper);
for (const child of toRemove) {
frame.remove(child);
}
// Clear original file bytes when resetting
inputs.length = 0;
splatsFolder.foldersRecursive().forEach((child) => child.destroy());
}
guiOptions.openCv = true;
guiOptions.autoRotate = false;
resetFrameQuaternion();
writeOptions.filename = "";
// Show progress bar if loading from URLs
const hasUrls = splatFiles.some(file => typeof file === "string");
if (hasUrls) {
showProgress();
}
let index = 0;
for (const splatFile of splatFiles) {
try {
let fileBytes;
let fileName;
let url = null;
// Check if splatFile is a URL string or a File object
if (typeof splatFile === "string") {
// It's a URL, fetch it
console.log(`Fetching ${splatFile}...`);
fileBytes = new Uint8Array(await fetchWithProgress(splatFile));
// Extract filename from URL
fileName = splatFile.split("/").pop().split("?")[0] || "downloaded-file";
if (isPcSogs(fileBytes)) {
url = splatFile;
}
} else {
// It's a File object
fileBytes = new Uint8Array(await splatFile.arrayBuffer());
fileName = splatFile.name;
}
if (writeOptions.filename === "") {
writeOptions.filename = fileName.split(".")[0];
}
const init = url ? { url } : { fileBytes: fileBytes.slice(), fileName };
init.splatEncoding = { ...splatEncoding };
const splatMesh = new SplatMesh(init);
const translate = guiOptions.loadOffset * index
splatMesh.position.set(translate, 0.5 * translate, 0.1 * translate);
splatMesh.enableWorldToView = true;
splatMesh.worldModifier = makeWorldModifier(splatMesh);
await splatMesh.initialized;
if (!url) {
// PC SOGS transcode not supported yet
inputs.push({ fileBytes, pathOrUrl: fileName, object: splatMesh });
}
frame.add(splatMesh);
console.log(`Loaded ${fileName} with ${splatMesh.numSplats} splats`);
addBoundingBoxHelper(splatMesh);
const splatFolder = splatsFolder.addFolder(fileName).close();
splatFolder.add(splatMesh, "opacity", 0, 1, 0.01).name("Opacity").listen();
splatFolder.add(splatMesh.position, "x", -10, 10, 0.01).name("X").listen();
splatFolder.add(splatMesh.position, "y", -10, 10, 0.01).name("Y").listen();
splatFolder.add(splatMesh.position, "z", -10, 10, 0.01).name("Z").listen();
splatFolder.add(splatMesh.scale, "x", 0.01, 4, 0.01).name("Scale").listen().onChange((value) => {
splatMesh.scale.setScalar(value);
});
splatFolder.add(splatMesh.rotation, "x", -Math.PI, Math.PI, 0.01).name("RotateX").listen();
splatFolder.add(splatMesh.rotation, "y", -Math.PI, Math.PI, 0.01).name("RotateY").listen();
splatFolder.add(splatMesh.rotation, "z", -Math.PI, Math.PI, 0.01).name("RotateZ").listen();
splatFolder.add(splatMesh, "maxSh", 0, 3, 1).name("Max SH").listen().onChange(() => {
splatMesh.updateGenerator();
});
} catch (error) {
console.error("Error loading splat file:", error);
}
index += 1;
}
// Hide progress bar when done
if (hasUrls) {
hideProgress();
}
// Restore focus to canvas for keyboard controls
canvas.focus();
}
async function addBoundingBoxHelper(splatMesh) {
await splatMesh.initialized;
const box = splatMesh.getBoundingBox();
const boxHelper = new THREE.Box3Helper(box, 0x00ff00);
boxHelper.visible = guiOptions.viewBoundingBox;
frame.add(boxHelper);
}
secondGui.add(guiOptions, "resetOnLoad").name("Reset on load");
secondGui.add(guiOptions, "loadOffset", -2, 2, 0.01).name("Loading offset");
secondGui.add(guiOptions, "openFiles").name("Select Files");
secondGui.add(guiOptions, "loadFromText").name("Paste URL(s) here");
secondGui.add(guiOptions, "loadFromTextAction").name("Load from URL(s)");
cameraFolder.add(guiOptions, "resetPose").name("Reset pose");
cameraFolder.add(guiOptions, "openCv").name("OpenCV coordinates")
.listen()
.onChange(resetFrameQuaternion);
cameraFolder.add(guiOptions, "autoRotate").name("Auto rotate")
.listen()
.onChange((value) => {
if (value) {
frame.rotation.y = 0;
}
});
cameraFolder.add(guiOptions, "orbit").name("Orbit controls")
.listen()
.onChange((value) => {
orbitControls.enabled = value;
canvas.focus();
rotX.enable(!value);
rotY.enable(!value);
rotZ.enable(!value);
});
// .setValue(false);
cameraFolder.add(guiOptions, "reversePointerDir").name("Reverse ptr direction")
.onChange((value) => {
controls.pointerControls.reverseRotate = value;
controls.pointerControls.reverseScroll = value;
});
cameraFolder.add(guiOptions, "reversePointerSlide").name("Reverse ptr slide")
.onChange((value) => {
controls.pointerControls.reverseSlide = value;
controls.pointerControls.reverseSwipe = value;
});
function setHighDpi(value) {
renderer.setPixelRatio(value ? window.devicePixelRatio : 1);
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
console.log("Render size", canvas.width, canvas.height);
}
setHighDpi(guiOptions.highDevicePixel);
gui.add(guiOptions, "highDevicePixel").name("High DPI").onChange((value) => {
setHighDpi(value);
});
gui.add(guiOptions, "stats").name("Show frame stats").onChange((value) => {
stats.dom.style.display = value ? "block" : "none";
});
gui.add(spark.defaultView, "sortRadial").name("Radial sort").listen();
gui.add(grid, "opacity", 0, 1, 0.01).name("Grid opacity").listen();
gui.add({
logFocalDistance: 0.0,
}, "logFocalDistance", -2, 2, 0.01).name("Ln(Focal distance)").onChange((value) => {
spark.focalDistance = Math.exp(value);
});
gui.add(spark, "apertureAngle", 0, 0.01 * Math.PI, 0.001).name("Aperture angle").listen();
scene.background = new THREE.Color(guiOptions.backgroundColor);
gui.addColor(guiOptions, "backgroundColor").name("Background color").onChange((value) => {
scene.background.set(value);
});
const debugFolder = gui.addFolder("Debug").close();
const normalColor = dyno.dynoBool(false);
debugFolder.add(normalColor, "value").name("Normal color").onChange(() => updateFrameSplats());
debugFolder.add(guiOptions, "viewBoundingBox").name("View bounding boxes").onChange((viewBoundingBox) => {
frame.children.forEach((child) => {
if (child instanceof THREE.Box3Helper) {
child.visible = viewBoundingBox;
}
});
});
debugFolder.add(spark, "maxStdDev", 0.1, 3.0, 0.01).name("Max Gsplat stddev").listen();
debugFolder.add(spark, "falloff", 0, 1, 0.01).name("Gaussian falloff").listen();
debugFolder.add(spark, "preBlurAmount", 0, 2, 0.1).name("Blur amount (no AA)").listen();
debugFolder.add(spark, "blurAmount", 0, 2, 0.1).name("Blur amount (AA)").listen();
debugFolder.add({
nonAA: () => {
spark.preBlurAmount = 0.3;
spark.blurAmount = 0.0;
},
}, "nonAA").name("Non-AA preset");
debugFolder.add({
AA: () => {
spark.preBlurAmount = 0.0;
spark.blurAmount = 0.3;
},
}, "AA").name("AA preset");
debugFolder.add(spark, "focalAdjustment", 0.1, 2.0, 0.1).name("Tweak focalAdjustment");
spark.defaultView.sort32 = true;
debugFolder.add(spark.defaultView, "sort32").name("Float32 sort").listen();
debugFolder.add(spark, "minPixelRadius", 0, 16, 0.1).name("Min pixel radius").listen();
debugFolder.add(spark, "maxPixelRadius", 1, 1024, 1).name("Max pixel radius").listen();
debugFolder.add(spark, "minAlpha", 0, 1, 0.001).name("Min alpha").listen();
debugFolder.add(spark, "premultipliedAlpha").name("Premultiplied alpha").listen();
debugFolder.add(spark.defaultView, "stochastic").name("Stochastic sort-free").listen().onChange(touch);
const accumFolder = debugFolder.addFolder("Accumulator encoding").close();;
accumFolder.add(spark.splatEncoding, "rgbMin", -1, 1, 0.1).name("RGB min").onChange(touch);
accumFolder.add(spark.splatEncoding, "rgbMax", 0, 4, 0.1).name("RGB max").onChange(touch);
accumFolder.add(spark.splatEncoding, "lnScaleMin", -14, -2.5, 0.1).name("Ln scale min").onChange(touch);
accumFolder.add(spark.splatEncoding, "lnScaleMax", -14, 14, 0.1).name("Ln scale max").onChange(touch);
const splatEncoding = {
rgbMin: 0.0,
rgbMax: 1.0,
lnScaleMin: LN_SCALE_MIN,
lnScaleMax: LN_SCALE_MAX,
sh1Min: -1,
sh1Max: 1,
sh2Min: -1,
sh2Max: 1,
sh3Min: -1,
sh3Max: 1,
};
const splatFolder = debugFolder.addFolder("SplatMesh encoding").close();
splatFolder.add(splatEncoding, "rgbMin", -1, 1, 0.1).name("RGB min").onChange(touch);
splatFolder.add(splatEncoding, "rgbMax", 0, 4, 0.1).name("RGB max").onChange(touch);
splatFolder.add(splatEncoding, "lnScaleMin", -14, -2.5, 0.1).name("Ln scale min").onChange(touch);
splatFolder.add(splatEncoding, "lnScaleMax", -14, 14, 0.1).name("Ln scale max").onChange(touch);
splatFolder.add(splatEncoding, "sh1Min", -6, 6, 0.1).name("SH1 min").onChange(touch);
splatFolder.add(splatEncoding, "sh1Max", -6, 6, 0.1).name("SH1 max").onChange(touch);
splatFolder.add(splatEncoding, "sh2Min", -6, 6, 0.1).name("SH2 min").onChange(touch);
splatFolder.add(splatEncoding, "sh2Max", -6, 6, 0.1).name("SH2 max").onChange(touch);
splatFolder.add(splatEncoding, "sh3Min", -6, 6, 0.1).name("SH3 min").onChange(touch);
splatFolder.add(splatEncoding, "sh3Max", -6, 6, 0.1).name("SH3 max").onChange(touch);
const splatsFolder = secondGui.addFolder("Files");
const clipFolder = gui.addFolder("Clip Splats").close();
function updateFrameSplats() {
frame.children.forEach((child) => {
if (child instanceof SplatMesh) {
child.updateVersion();
}
});
}
const clipEnable = dyno.dynoBool(false);
const clipMinX = dyno.dynoFloat(-5);
const clipMaxX = dyno.dynoFloat(5);
const clipMinY = dyno.dynoFloat(-5);
const clipMaxY = dyno.dynoFloat(5);
const clipMinZ = dyno.dynoFloat(-5);
const clipMaxZ = dyno.dynoFloat(5);
clipFolder.add(clipEnable, "value").name("Enable clip").onChange(() => updateFrameSplats());
clipFolder.add(clipMinX, "value", -50, 50, 0.01).name("Min X").onChange(() => updateFrameSplats());
clipFolder.add(clipMaxX, "value", -50, 50, 0.01).name("Max X").onChange(() => updateFrameSplats());
clipFolder.add(clipMinY, "value", -50, 50, 0.01).name("Min Y").onChange(() => updateFrameSplats());
clipFolder.add(clipMaxY, "value", -50, 50, 0.01).name("Max Y").onChange(() => updateFrameSplats());
clipFolder.add(clipMinZ, "value", -50, 50, 0.01).name("Min Z").onChange(() => updateFrameSplats());
clipFolder.add(clipMaxZ, "value", -50, 50, 0.01).name("Max Z").onChange(() => updateFrameSplats());
function makeWorldModifier(mesh) {
const context = mesh.context;
return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => {
// Color by world normal if it's enabled
let worldNormal = dyno.gsplatNormal(gsplat);
let { rgb, center, opacity } = dyno.splitGsplat(gsplat).outputs;
// Compute vector from view to object coordinate in gsplat
const worldToView = context.worldToView;
const viewGsplat = worldToView.applyGsplat(gsplat);
const viewCenter = dyno.splitGsplat(viewGsplat).outputs.center;
let normal = dyno.gsplatNormal(viewGsplat);
const dot = dyno.dot(viewCenter, normal);
const sameDir = dyno.greaterThanEqual(dot, dyno.dynoConst("float", 0));
normal = dyno.select(sameDir, dyno.neg(worldNormal), worldNormal);
const normalRgb = dyno.add(dyno.mul(normal, dyno.dynoConst("float", 0.5)), dyno.dynoConst("float", 0.5));
rgb = dyno.select(normalColor, normalRgb, rgb);
// Zero out opacity if outside clip bounds
const { x, y, z } = dyno.split(center).outputs;
const xWithin = dyno.and(dyno.greaterThanEqual(x, clipMinX), dyno.lessThanEqual(x, clipMaxX));
const yWithin = dyno.and(dyno.greaterThanEqual(y, clipMinY), dyno.lessThanEqual(y, clipMaxY));
const zWithin = dyno.and(dyno.greaterThanEqual(z, clipMinZ), dyno.lessThanEqual(z, clipMaxZ));
const within = dyno.and(dyno.and(xWithin, yWithin), zWithin);
const splatEnabled = dyno.or(
dyno.not(clipEnable),
within,
);
opacity = dyno.select(splatEnabled, opacity, dyno.dynoConst("float", 0));
gsplat = dyno.combineGsplat({
gsplat,
rgb,
opacity,
})
return { gsplat };
});
}
const exportFolder = secondGui.addFolder("Export Gsplats").close();
const writeOptions = {
filename: "gsplats",
trimOpacity: true,
trimOpacityThreshold: 0,
maxSh: 3,
fractionalBits: 12,
writeSpz: async () => {
const transcodeInputs = inputs.map((input) => {
return {
fileBytes: new Uint8Array(input.fileBytes),
pathOrUrl: input.pathOrUrl,
transform: {
translate: input.object.position.toArray(),
quaternion: input.object.quaternion.toArray(),
scale: (input.object.scale.x + input.object.scale.y + input.object.scale.z) / 3,
},
};
});
const clipXyz = clipEnable.value ? {
min: [clipMinX.value, clipMinY.value, clipMinZ.value],
max: [clipMaxX.value, clipMaxY.value, clipMaxZ.value],
} : undefined;
const maxSh = writeOptions.maxSh;
const fractionalBits = writeOptions.fractionalBits;
const opacityThreshid = writeOptions.trimOpacity ? writeOptions.trimOpacityThreshold : undefined;
const transcode = await transcodeSpz({ inputs: transcodeInputs, maxSh, clipXyz, fractionalBits, opacityThreshold: opacityThreshid });
if (transcode.clippedCount && transcode.clippedCount > 0) {
console.log(`Clipped ${transcode.clippedCount} splats. Consider decreasing fractional-bits from ${fractionalBits} to reduce clipping.`);
alert(`Clipped ${transcode.clippedCount} splats. Consider decreasing fractional-bits from ${fractionalBits} to reduce clipping.`);
}
const blob = new Blob([transcode.fileBytes], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = writeOptions.filename + ".spz";
a.click();
URL.revokeObjectURL(url);
},
};
exportFolder.add(writeOptions, "filename").name("Filename").listen();
exportFolder.add(writeOptions, "trimOpacity").name("Trim low opacity");
exportFolder.add(writeOptions, "trimOpacityThreshold").name("Trim opacity <= 0..1");
exportFolder.add(writeOptions, "maxSh", 0, 3, 1).name("Max spherical harmonics");
exportFolder.add(writeOptions, "fractionalBits", 6, 24, 1).name("Fractional bits");
exportFolder.add(writeOptions, "writeSpz").name("Create .spz and download");
function makeInstructions() {
const instructions = textSplats({
text: "Drag and Drop\na Gsplat file\nhere to view",
textAlign: "center",
fontSize: 64,
objectScale: 0.1 / 64,
});
instructions.quaternion.set(1, 0, 0, 0);
instructions.enableWorldToView = true;
instructions.worldModifier = makeWorldModifier(instructions);
instructions.updateGenerator();
return instructions;
}
const instructions = makeInstructions();
addBoundingBoxHelper(instructions);
frame.add(instructions);
// Load URLs from query parameters if any
const urlsToLoad = parseURLsFromQuery();
if (urlsToLoad.length > 0) {
console.log(`Loading ${urlsToLoad.length} URLs from query parameters:`, urlsToLoad);
loadFiles(urlsToLoad);
}
// Focus the canvas initially for keyboard controls
canvas.focus();
// Drag and drop functionality
canvas.addEventListener("dragover", (e) => {
e.preventDefault();
canvas.style.opacity = "0.5";
});
canvas.addEventListener("dragleave", (e) => {
e.preventDefault();
canvas.style.opacity = "1";
});
canvas.addEventListener("drop", async (e) => {
e.preventDefault();
canvas.style.opacity = "1";
// Check for text data first
const textData = e.dataTransfer.getData('text/plain');
if (textData) {
const urls = parseURLsFromText(textData);
if (urls.length > 0) {
console.log(`Found ${urls.length} URLs in dropped text:`, urls);
loadFiles(urls);
return;
}
}
// Fallback to file handling
const files = Array.from(e.dataTransfer.files);
console.log("Dropped files:", files.length, files);
const splatFiles = files.filter((file) =>
file.name.toLowerCase().endsWith('.ply') ||
file.name.toLowerCase().endsWith('.spz') ||
file.name.toLowerCase().endsWith('.splat') ||
file.name.toLowerCase().endsWith('.ksplat') ||
file.name.toLowerCase().endsWith('.zip') ||
file.name.toLowerCase().endsWith('.sog')
);
if (splatFiles.length > 0) {
loadFiles(splatFiles);
}
});
// Parse URLs from text (handles single URLs, multiple lines, mixed content)
function parseURLsFromText(text) {
const supportedExtensions = [".ply", ".spz", ".splat", ".ksplat", ".zip", ".json"];
const urls = [];
// Split by lines, commas, and semicolons
const parts = text.trim().split(/[\r\n,;]+/);
for (const part of parts) {
const trimmed = part.trim();
// Check if part looks like a URL
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
// Check if it has a supported extension
const hasValidExtension = supportedExtensions.some(ext =>
trimmed.toLowerCase().includes(ext)
);
if (hasValidExtension) {
urls.push(trimmed);
}
}
}
return urls;
}
let lastTime = null;
renderer.setAnimationLoop(function animate(time) {
const deltaTime = time - (lastTime || time);
lastTime = time;
stats.begin();
if (guiOptions.autoRotate) {
frame.rotation.y += deltaTime / 5000;
}
if (guiOptions.orbit) {
orbitControls.update();
} else {
controls.update(camera);
}
renderer.render(scene, camera);
stats.end();
});
</script>
</body>
</html>
+136
View File
@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Spark • Environment Mapping</title>
<style>
body {
margin: 0;
}
label {
font-family: sans-serif;
font-size: 20px;
position: absolute;
color: white;
cursor: pointer;
padding: 10px;
border-radius: 10px;
display: block;
width: 100px;
left: calc(50% - 50px);
top: 10px;
text-align: center;
background-color: black;
}
</style>
</head>
<body>
<label><input type="checkbox" onchange="toggleMetal(this)" autocomplete="off"> Plastic</label>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SplatMesh, SparkRenderer, PackedSplats } from "@sparkjsdev/spark";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.y = 0.5;
const renderer = new THREE.WebGLRenderer();
renderer.setClearColor(new THREE.Color(0x1b2037), 1.0);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// Explicitly create a SparkRenderer to render environment maps
const spark = new SparkRenderer({ renderer });
scene.add(spark);
const splatURL = await getAssetFileURL("fireplace.spz");
const packedSplats = new PackedSplats({ url: splatURL });
const background = new SplatMesh({ packedSplats });
background.quaternion.set(1, 0, 0, 0);
background.position.set(0.5, 0, -1);
background.scale.setScalar(0.5);
scene.add(background);
const background2 = new SplatMesh({ packedSplats });
background2.quaternion.set(1, 0, 0, 0);
background2.rotation.y = Math.PI;
background2.position.set(-0.5, 0, 0.0);
background2.scale.setScalar(0.5);
scene.add(background2);
// Make sure background is loaded before rendering env map
await background.initialized;
await background2.initialized;
// Add rubber duck
const gltfLoader = new GLTFLoader();
const modelURL = await getAssetFileURL('rubberduck.glb');
const gltf = await gltfLoader.loadAsync(modelURL);
const duck = gltf.scene;
duck.position.set(0, 0.45, -0.4);
scene.add(duck);
let lastTime;
let renderedEnvMap = false;
renderer.setAnimationLoop(function animate(time) {
const deltaTime = time - (lastTime || time);
lastTime = time;
renderer.render(scene, camera);
duck.rotation.y += deltaTime / 2500;
// Make sure we only launch async renderEnvMap once
if (!renderedEnvMap) {
renderedEnvMap = true;
spark.renderEnvMap({
scene,
// World center to render from
worldCenter: duck.position,
// Hide the duck so we don't obscure the environment
hideObjects: [duck],
// Not needed in normal use, guards against "first render" issues
update: true,
})
.then((envMap) => {
for (let obj of duck.children) {
// Set some reflectivity in the materials of the duck
obj.material.envMap = envMap;
obj.material.original_color = obj.material.color.clone();
obj.material.metalness = 1.0;
obj.material.roughness = 0.02;
}
});
}
});
// Toggles between normal duck and metal duck
window.toggleMetal = function toggleMetal(el) {
const metal = el.checked;
for (let obj of duck.children) {
obj.material.metalness = metal ? 0.0 : 1.0;
obj.material.roughness = metal ? 0.2 : 0.02;
}
}
</script>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • GLSL Shaders</title>
<style>
body {
margin: 0;
}
canvas {
touch-action: none;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SparkControls, SplatMesh, dyno } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
const splatURL = await getAssetFileURL("butterfly.spz");
const butterfly = new SplatMesh({ url: splatURL });
butterfly.quaternion.set(1, 0, 0, 0);
butterfly.position.set(0, 0, -1.5);
scene.add(butterfly);
// A Gsplat type is defined in GLSL in src/dyno/splats.ts as follows:
// struct Gsplat {
// vec3 center;
// uint flags;
// vec3 scales;
// int index;
// vec4 quaternion;
// vec4 rgba;
// };
// const uint GSPLAT_FLAG_ACTIVE = 1u << 0u;
// Inject GLSL color shader into world-space SplatMesh modifier
butterfly.worldModifier = new dyno.Dyno({
inTypes: { gsplat: dyno.Gsplat },
outTypes: { gsplat: dyno.Gsplat },
globals: () => [dyno.unindent(`
vec3 waveRgb(vec3 pos) {
return vec3(
0.6 + 0.4 * sin(pos.x * 56.0),
0.6 + 0.4 * sin(pos.y * 78.0),
0.6 + 0.4 * cos(pos.z * 90.0)
);
}
`)],
statements: ({ inputs, outputs }) => dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
${outputs.gsplat}.rgba.rgb *= waveRgb(${inputs.gsplat}.center);
`),
});
// Inject DynoBlock with embedded GLSL shader that uses an external
// uniform value animateT to control motion. Any GLSL statements can only
// uses references provided in inputs + outputs, so we create a DynoBlock
// that allows us to get `t` as an input parameter to our GLSL block.
const animateT = dyno.dynoFloat(0);
butterfly.objectModifier = dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => {
// Create an inline GLSL block that has all the inputs we need
const d = new dyno.Dyno({
inTypes: { gsplat: dyno.Gsplat, t: "float" },
outTypes: { gsplat: dyno.Gsplat },
globals: () => [
dyno.unindent(`
float warpRadial(float r, float t) {
return r * (1.0 + 0.1 * sin(r * 15.0 + t * 3.0));
}
vec3 warp(vec3 pos, float t) {
float r = length(pos);
float newR = warpRadial(r, t);
return pos * (newR / r);
}
`)
],
statements: ({ inputs, outputs }) => dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
${outputs.gsplat}.center = warp(${inputs.gsplat}.center, ${inputs.t});
`),
});
// Apply the GLSL block with appropriate inputs and return the output
gsplat = d.apply({ gsplat, t: animateT }).gsplat;
return { gsplat };
},
);
// Make sure to update the generator after updating modifiers
butterfly.updateGenerator();
const controls = new SparkControls({ canvas: renderer.domElement });
renderer.setAnimationLoop(function animate(time) {
animateT.value = time / 1000;
butterfly.updateVersion();
controls.update(camera);
renderer.render(scene, camera);
});
</script>
</body>
</html>
+114
View File
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<title>Spark • Carousel</title>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SplatMesh } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setClearColor(0x000000, 1);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Setup mouse controls to orbit the camera around
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, -3);
controls.minDistance = 2;
controls.maxDistance = 3;
controls.enablePan = false;
controls.enabled = true;
controls.update();
const ASSETS = [
{ file: "butterfly.spz", scale: 1.2 },
{ file: "furry-logo-pedestal.spz", scale: 0.45 },
{ file: "forge.spz", scale: 1 },
];
const assets = [];
for (let asset of ASSETS) {
const splatURL = await getAssetFileURL(asset.file);
const splat = new SplatMesh({ url: splatURL });
splat.quaternion.set(1, 0, 0, 0);
splat.position.set(0, 0, -3);
splat.scale.setScalar(asset.scale);
assets.push(splat);
}
let current = 0;
scene.add(assets[current]);
const FADE_TIME = 1000; //ms
let fadeOut = null;
let fadeIn = null;
let prevTime;
renderer.setAnimationLoop(function animate(time) {
const dt = time - (prevTime ?? time);
prevTime = time;
renderer.render(scene, camera);
assets[current].rotation.y = time / 1500;
controls.update();
// fade out
if (fadeOut !== null) {
fadeOut += dt;
if (fadeOut < FADE_TIME) {
assets[current].opacity = 1 - easeInOutSine(fadeOut / FADE_TIME);
} else {
fadeOut = null;
fadeIn = 0;
scene.remove(assets[current]);
current = (current + 1) % assets.length;
scene.add(assets[current]);
}
}
// fade in
if (fadeIn !== null) {
fadeIn += dt;
if (fadeIn < FADE_TIME) {
assets[current].opacity = easeInOutSine(fadeIn / FADE_TIME);
} else {
fadeIn = null;
}
}
});
// start fadeout every x seconds
setInterval(() => { fadeOut = 0 }, 5000);
function easeInOutSine(x) {
return -(Math.cos(Math.PI * x) - 1) / 2;
}
</script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Hello World</title>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SplatMesh } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
const splatURL = await getAssetFileURL("butterfly.spz");
const butterfly = new SplatMesh({ url: splatURL });
butterfly.quaternion.set(1, 0, 0, 0);
butterfly.position.set(0, 0, -3);
scene.add(butterfly);
let lastTime;
renderer.setAnimationLoop(function animate(time) {
const deltaTime = time - (lastTime || time);
lastTime = time;
renderer.render(scene, camera);
butterfly.rotation.y += deltaTime / 2500;
});
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0;url=/" />
</head>
<body>
</body>
</html>
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Splat Experiment</title>
<style>
body {
margin: 0;
overflow: hidden;
font-family: Arial, sans-serif;
background: #000;
}
header {
position: absolute;
color: silver;
font-family: sans-serif;
padding: 12px 16px;
text-align: left;
width: 100vw;
pointer-events: none;
text-shadow:
2px 2px 4px rgba(0, 0, 0, 0.8),
-1px -1px 2px rgba(0, 0, 0, 0.6),
1px -1px 2px rgba(0, 0, 0, 0.6),
-1px 1px 2px rgba(0, 0, 0, 0.6);
-webkit-text-stroke: 0.5px rgba(0, 0, 0, 0.7);
}
</style>
</head>
<body>
<header>Click and drag on the penguin to deform it • Release to see elastic bounce • A/D to rotate • W/S to zoom • Adjust parameters with GUI controls</header>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"@sparkjsdev/spark": "/dist/spark.module.js",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js"
}
}
</script>
<script type="module" src="main.js"></script>
</body>
</html>
@@ -0,0 +1,318 @@
import { SparkRenderer, SplatMesh, dyno } from "@sparkjsdev/spark";
import { GUI } from "lil-gui";
import * as THREE from "three";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
const renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const spark = new SparkRenderer({ renderer });
scene.add(spark);
window.addEventListener("resize", onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
let rotationAngle = 0;
let zoomDistance = 5.5;
const minZoom = 1;
const maxZoom = 20;
const rotationSpeed = 0.02;
const zoomSpeed = 0.1;
camera.position.set(0, 3, zoomDistance);
camera.lookAt(0, 1, 0);
const keys = {};
window.addEventListener("keydown", (event) => {
keys[event.key.toLowerCase()] = true;
});
window.addEventListener("keyup", (event) => {
keys[event.key.toLowerCase()] = false;
});
// Dyno uniforms for drag and bounce effects
const dragPoint = dyno.dynoVec3(new THREE.Vector3(0, 0, 0));
const dragDisplacement = dyno.dynoVec3(new THREE.Vector3(0, 0, 0));
const dragRadius = dyno.dynoFloat(0.5);
const dragActive = dyno.dynoFloat(0.0);
const bounceTime = dyno.dynoFloat(0.0);
const bounceBaseDisplacement = dyno.dynoVec3(new THREE.Vector3(0, 0, 0));
const dragIntensity = dyno.dynoFloat(5.0);
const bounceAmount = dyno.dynoFloat(0.5);
const bounceSpeed = dyno.dynoFloat(0.5);
let isBouncing = false;
const gui = new GUI();
const guiParams = {
intensity: dragIntensity.value,
radius: 0.5,
bounceAmount: 0.5,
bounceSpeed: 0.5,
};
gui
.add(guiParams, "intensity", 0, 10.0, 0.1)
.name("Deformation Strength")
.onChange((value) => {
dragIntensity.value = value;
if (splatMesh) {
splatMesh.updateVersion();
}
});
gui
.add(guiParams, "radius", 0.25, 1.0, 0.1)
.name("Drag Radius")
.onChange((value) => {
dragRadius.value = value;
if (splatMesh) {
splatMesh.updateVersion();
}
});
gui
.add(guiParams, "bounceAmount", 0, 1.0, 0.1)
.name("Bounce Strength")
.onChange((value) => {
bounceAmount.value = value;
if (splatMesh) {
splatMesh.updateVersion();
}
});
gui
.add(guiParams, "bounceSpeed", 0, 1.0, 0.01)
.name("Bounce Speed")
.onChange((value) => {
bounceSpeed.value = value;
if (splatMesh) {
splatMesh.updateVersion();
}
});
let isDragging = false;
let dragStartPoint = null;
let currentDragPoint = null;
const raycaster = new THREE.Raycaster();
raycaster.params.Points = { threshold: 0.5 };
function createDragBounceDynoshader() {
return dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => {
const shader = new dyno.Dyno({
inTypes: {
gsplat: dyno.Gsplat,
dragPoint: "vec3",
dragDisplacement: "vec3",
dragRadius: "float",
dragActive: "float",
bounceTime: "float",
bounceBaseDisplacement: "vec3",
dragIntensity: "float",
bounceAmount: "float",
bounceSpeed: "float",
},
outTypes: { gsplat: dyno.Gsplat },
statements: ({ inputs, outputs }) =>
dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
vec3 originalPos = ${inputs.gsplat}.center;
// Calculate influence based on distance from drag point
float distToDrag = distance(originalPos, ${inputs.dragPoint});
float dragInfluence = 1.0 - smoothstep(0.0, ${inputs.dragRadius}*2., distToDrag);
float time = ${inputs.bounceTime};
// Apply drag deformation
if (${inputs.dragActive} > 0.5 && ${inputs.dragRadius} > 0.0) {
vec3 dragOffset = ${inputs.dragDisplacement} * dragInfluence * ${inputs.dragIntensity} * 50.0;
originalPos += dragOffset;
}
// Apply elastic bounce effect
float bounceFrequency = 1.0 + ${inputs.bounceSpeed} * 8.0;
vec3 bounceOffset = ${inputs.bounceBaseDisplacement} * dragInfluence * ${inputs.dragIntensity} * 50.0;
originalPos += bounceOffset * cos(time*bounceFrequency) * exp(-time*2.0*(1.0-${inputs.bounceAmount}*.9));
${outputs.gsplat}.center = originalPos;
`),
});
return {
gsplat: shader.apply({
gsplat,
dragPoint: dragPoint,
dragDisplacement: dragDisplacement,
dragRadius: dragRadius,
dragActive: dragActive,
bounceTime: bounceTime,
bounceBaseDisplacement: bounceBaseDisplacement,
dragIntensity: dragIntensity,
bounceAmount: bounceAmount,
bounceSpeed: bounceSpeed,
}).gsplat,
};
},
);
}
let splatMesh = null;
async function loadSplat() {
const splatURL = await getAssetFileURL("penguin.spz");
splatMesh = new SplatMesh({ url: splatURL });
splatMesh.quaternion.set(1, 0, 0, 0);
splatMesh.position.set(0, 0, 0);
scene.add(splatMesh);
await splatMesh.initialized;
splatMesh.worldModifier = createDragBounceDynoshader();
splatMesh.updateGenerator();
}
loadSplat().catch((error) => {
console.error("Error loading splat:", error);
});
// Convert mouse coordinates to normalized device coordinates
function getMouseNDC(event) {
const rect = renderer.domElement.getBoundingClientRect();
return new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
}
// Raycast to find intersection point on splat
function getHitPoint(ndc) {
if (!splatMesh) return null;
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObject(splatMesh, false);
if (hits && hits.length > 0) {
return hits[0].point.clone();
}
return null;
}
let dragStartNDC = null;
let dragScale = 1.0;
renderer.domElement.addEventListener("pointerdown", (event) => {
if (!splatMesh) return;
const ndc = getMouseNDC(event);
const hitPoint = getHitPoint(ndc);
if (hitPoint) {
isDragging = true;
dragStartNDC = ndc.clone();
dragStartPoint = hitPoint.clone();
currentDragPoint = hitPoint.clone();
// Calculate scale factor for screen-to-world conversion
const distanceToCamera = camera.position.distanceTo(hitPoint);
const fov = camera.fov * (Math.PI / 180);
const screenHeight = 2.0 * Math.tan(fov / 2.0) * distanceToCamera;
dragScale = screenHeight / window.innerHeight;
dragPoint.value.copy(hitPoint);
dragActive.value = 1.0;
dragRadius.value = guiParams.radius;
dragDisplacement.value.set(0, 0, 0);
bounceTime.value = -1.0;
bounceBaseDisplacement.value.set(0, 0, 0);
isBouncing = false;
}
});
renderer.domElement.addEventListener("pointermove", (event) => {
if (!isDragging || !splatMesh || !dragStartPoint || !dragStartNDC) return;
const ndc = getMouseNDC(event);
// Convert screen space movement to world space
const mouseDelta = new THREE.Vector2(
(ndc.x - dragStartNDC.x) * dragScale,
(ndc.y - dragStartNDC.y) * dragScale,
);
const cameraRight = new THREE.Vector3();
const cameraUp = new THREE.Vector3();
camera.getWorldDirection(new THREE.Vector3());
cameraRight.setFromMatrixColumn(camera.matrixWorld, 0).normalize();
cameraUp.setFromMatrixColumn(camera.matrixWorld, 1).normalize();
const worldDisplacement = new THREE.Vector3()
.addScaledVector(cameraRight, mouseDelta.x)
.addScaledVector(cameraUp, mouseDelta.y);
currentDragPoint = dragStartPoint.clone().add(worldDisplacement);
dragDisplacement.value.copy(worldDisplacement);
});
renderer.domElement.addEventListener("pointerup", (event) => {
if (!isDragging) return;
isDragging = false;
// Start bounce animation with final displacement
if (currentDragPoint && dragStartPoint) {
const finalDisplacement = currentDragPoint.clone().sub(dragStartPoint);
bounceBaseDisplacement.value.copy(dragDisplacement.value);
bounceTime.value = 0.0;
isBouncing = true;
}
dragActive.value = 0.0;
dragDisplacement.value.set(0, 0, 0);
dragStartNDC = null;
});
renderer.setAnimationLoop(() => {
// Update bounce animation
if (isBouncing) {
bounceTime.value += 0.1;
if (splatMesh) {
splatMesh.updateVersion();
}
}
// Keyboard controls
if (keys.a) {
rotationAngle -= rotationSpeed;
}
if (keys.d) {
rotationAngle += rotationSpeed;
}
if (keys.w) {
zoomDistance = Math.max(minZoom, zoomDistance - zoomSpeed);
}
if (keys.s) {
zoomDistance = Math.min(maxZoom, zoomDistance + zoomSpeed);
}
// Update camera orbit
camera.position.x = Math.sin(rotationAngle) * zoomDistance;
camera.position.z = Math.cos(rotationAngle) * zoomDistance;
camera.position.y = 3;
camera.lookAt(0, 1.5, 0);
if (splatMesh) {
splatMesh.updateVersion();
}
renderer.render(scene, camera);
});
@@ -0,0 +1,461 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Interactive Holes (Click Radius)</title>
<style>
body {
margin: 0;
overflow: hidden;
background: #000;
}
header {
position: absolute;
color: silver;
font-family: sans-serif;
padding: 12px 16px;
text-align: left;
width: 100vw;
pointer-events: none;
text-shadow:
2px 2px 4px rgba(0, 0, 0, 0.8),
-1px -1px 2px rgba(0, 0, 0, 0.6),
1px -1px 2px rgba(0, 0, 0, 0.6),
-1px 1px 2px rgba(0, 0, 0, 0.6);
-webkit-text-stroke: 0.5px rgba(0, 0, 0, 0.7);
}
</style>
</head>
<body>
<header>Mouse to look around • Click on a splat to create interactive holes within a radius</header>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SplatMesh, dyno, SparkControls, SplatEdit, SplatEditSdf, SplatEditSdfType, SplatEditRgbaBlendMode } from "@sparkjsdev/spark";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { GUI } from "lil-gui";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
// Scene
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 50);
camera.position.set(0, -0.3, -3);
camera.lookAt(0, 0, 1);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Controls (mouse only, WASD disabled)
const controls = new SparkControls({ canvas: renderer.domElement });
controls.fpsMovement.enable = false; // Disable WASD movement
// Lights
// Uniforms for click-radius interactive holes (multi-impulse)
const animationTime = dyno.dynoFloat(0.0);
const uExplosionStrength = dyno.dynoFloat(4.0);
const uFriction = dyno.dynoFloat(0.98);
const uGravity = dyno.dynoFloat(9.8);
const uBounceDamping = dyno.dynoFloat(0.45);
const uFloorLevel = dyno.dynoFloat(-1.1);
const uShrinkSpeed = dyno.dynoFloat(5.0);
const uExplosionRadius = dyno.dynoFloat(0.3); // GUI-controlled radius
const MAX_IMPULSES = 128; // Increased buffer size
const impulses = Array.from({ length: MAX_IMPULSES }, () => ({
center: dyno.dynoVec3(new THREE.Vector3(0, 0, 0)),
radius: dyno.dynoFloat(0.0),
start: dyno.dynoFloat(0.0),
active: dyno.dynoFloat(0.0),
}));
let impulseWriteIndex = 0;
// Depth selection state: choose deeper intersection on repeated clicks near same pixel
let lastClickNDC = new THREE.Vector2(999, 999);
let depthIndex = 0; // reserved if we want stepped indices later
let stackedDepth = 0.0; // actual accumulated depth distance
const DEPTH_STEP = 0.08;
const NDC_PROXIMITY = 0.1; // threshold in NDC to consider repeated click
// Dyno program with radial gating based on click center and radius
function createInteractiveHolesDynoshader() {
return dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => {
// Generate input types dynamically
const inputTypes = {
gsplat: dyno.Gsplat,
time: "float",
explosionStrength: "float",
gravity: "float",
bounceDamping: "float",
floorLevel: "float",
friction: "float",
shrinkSpeed: "float",
};
// Add impulse parameters dynamically
for (let i = 0; i < MAX_IMPULSES; i++) {
inputTypes[`clickCenter${i}`] = "vec3";
inputTypes[`clickRadius${i}`] = "float";
inputTypes[`clickStart${i}`] = "float";
inputTypes[`clickActive${i}`] = "float";
}
const shader = new dyno.Dyno({
inTypes: inputTypes,
outTypes: { gsplat: dyno.Gsplat },
globals: () => [
dyno.unindent(`
float hash(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); }
vec3 simulatePhysics(vec3 originalPos, float dropTime, float gravity, float damping, float floorLevel, float friction, float explosionStrength) {
float timeVariation = hash(originalPos + vec3(42.0)) * 0.2 - 0.1;
float t = max(0.0, dropTime + timeVariation);
vec3 initialVelocity = vec3(
(hash(originalPos + vec3(1.0)) - 0.5) * explosionStrength * (0.3 + hash(originalPos + vec3(10.0)) * 0.4),
abs(hash(originalPos + vec3(3.0))) * explosionStrength * (0.8 + hash(originalPos + vec3(20.0)) * 0.4) + 0.5,
(hash(originalPos + vec3(2.0)) - 0.5) * explosionStrength * (0.3 + hash(originalPos + vec3(30.0)) * 0.4)
);
float frictionDecay = pow(friction, t * 60.0);
vec3 position = originalPos;
position.x += initialVelocity.x * (1.0 - frictionDecay) / (1.0 - friction) / 60.0;
position.z += initialVelocity.z * (1.0 - frictionDecay) / (1.0 - friction) / 60.0;
position.y += initialVelocity.y * t - 0.5 * gravity * t * t;
if (position.y <= floorLevel) {
float bounceTime = t;
float bounceCount = floor(bounceTime * 3.0);
float timeSinceBounce = bounceTime - bounceCount / 3.0;
float bounceHeight = initialVelocity.y * pow(damping, bounceCount) * max(0.0, 1.0 - timeSinceBounce * 3.0);
if (bounceHeight > 0.1) {
position.y = floorLevel + abs(sin(timeSinceBounce * 3.14159 * 3.0)) * bounceHeight;
} else {
position.y = floorLevel;
float scatterFactor = hash(originalPos + vec3(50.0)) * 0.2;
position.x += (hash(originalPos + vec3(60.0)) - 0.5) * scatterFactor;
position.z += (hash(originalPos + vec3(70.0)) - 0.5) * scatterFactor;
}
}
return position;
}
`),
],
statements: ({ inputs, outputs }) => dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
vec3 originalPos = ${inputs.gsplat}.center;
const int K = ${MAX_IMPULSES};
vec3 centers[K];
float radii[K];
float starts[K];
float actives[K];
${Array.from({ length: MAX_IMPULSES }, (_, i) =>
`centers[${i}] = ${inputs[`clickCenter${i}`]}; radii[${i}] = ${inputs[`clickRadius${i}`]}; starts[${i}] = ${inputs[`clickStart${i}`]}; actives[${i}] = ${inputs[`clickActive${i}`]};`
).join('\n ')}
float maskUnion = 0.0;
float tMax = 0.0;
for (int i = 0; i < K; i++) {
float m = actives[i] > 0.5 ? step(distance(originalPos, centers[i]), radii[i]) : 0.0;
maskUnion = max(maskUnion, m);
float ti = max(0.0, ${inputs.time} - starts[i]);
tMax = max(tMax, ti * m);
}
if (maskUnion > 0.0) {
float strength = ${inputs.explosionStrength};
vec3 physicsPos = simulatePhysics(originalPos, tMax, ${inputs.gravity}, ${inputs.bounceDamping}, ${inputs.floorLevel}, ${inputs.friction}, strength);
float factor = exp(-tMax * ${inputs.shrinkSpeed});
vec3 currentScale = mix(${inputs.gsplat}.scales, vec3(0.005), 1.0 - factor);
${outputs.gsplat}.center = physicsPos;
${outputs.gsplat}.scales = currentScale;
}
`),
});
// Generate apply parameters dynamically
const applyParams = {
gsplat,
time: animationTime,
explosionStrength: uExplosionStrength,
gravity: uGravity,
bounceDamping: uBounceDamping,
floorLevel: uFloorLevel,
friction: uFriction,
shrinkSpeed: uShrinkSpeed,
};
// Add impulse parameters dynamically
for (let i = 0; i < MAX_IMPULSES; i++) {
applyParams[`clickCenter${i}`] = impulses[i].center;
applyParams[`clickRadius${i}`] = impulses[i].radius;
applyParams[`clickStart${i}`] = impulses[i].start;
applyParams[`clickActive${i}`] = impulses[i].active;
}
gsplat = shader.apply(applyParams).gsplat;
return { gsplat };
},
);
}
// Load a demo splat and a floor model to give context
const splatName = "painted-bedroom.spz";
const splatURL = await getAssetFileURL(splatName);
const splatMesh = new SplatMesh({ url: splatURL });
await splatMesh.initialized;
splatMesh.rotation.x = Math.PI;
splatMesh.position.set(0, 0, 0);
splatMesh.scale.set(1,1, 1);
splatMesh.worldModifier = createInteractiveHolesDynoshader();
splatMesh.updateGenerator();
scene.add(splatMesh);
// Persistent interactive holes edit (permanently removes splats in clicked regions)
const interactiveHolesEdit = new SplatEdit({
rgbaBlendMode: SplatEditRgbaBlendMode.MULTIPLY,
softEdge: 0.02,
sdfSmooth: 0.0,
});
splatMesh.add(interactiveHolesEdit);
// -------- CPU baking of centers so they stay on the floor and raycast uses updated positions --------
// Half-float helpers (encode/decode)
function floatToHalf(val) {
const floatView = new Float32Array(1);
const int32View = new Int32Array(floatView.buffer);
floatView[0] = val;
const x = int32View[0];
const bits = (x >>> 16) & 0x8000; // sign
const m = (x >>> 12) & 0x07ff; // mantissa
const e = (x >>> 23) & 0xff; // exponent
if (e < 103) return bits; // too small => 0
if (e > 142) return bits | 0x7c00; // too large => inf
let half = bits | ((e - 112) << 10) | (m >> 1);
return half;
}
function halfToFloat(h) {
const s = (h & 0x8000) >> 15;
let e = (h & 0x7c00) >> 10;
let f = h & 0x03ff;
if (e === 0) {
if (f === 0) return s ? -0 : 0;
// subnormal
return (s ? -1 : 1) * Math.pow(2, -14) * (f / 1024);
} else if (e === 31) {
return f ? NaN : (s ? -Infinity : Infinity);
}
return (s ? -1 : 1) * Math.pow(2, e - 15) * (1 + f / 1024);
}
// Decode all centers once and keep an up-to-date CPU-side array (friendly space with y/z flipped)
const packed = splatMesh.packedSplats.packedArray;
const originalPacked = packed.slice();
const numSplats = splatMesh.packedSplats.numSplats;
const centersFriendly = new Float32Array(numSplats * 3);
(function decodeCenters() {
for (let i = 0; i < numSplats; i++) {
const i4 = i * 4;
const w1 = packed[i4 + 1];
const w2 = packed[i4 + 2];
const uX = w1 & 0xffff;
const uY = (w1 >>> 16) & 0xffff;
const uZ = w2 & 0xffff;
const x = halfToFloat(uX);
const y = halfToFloat(uY);
const z = halfToFloat(uZ);
// Convert to friendly space used by clicks (y/z flipped)
centersFriendly[i * 3 + 0] = x;
centersFriendly[i * 3 + 1] = -y;
centersFriendly[i * 3 + 2] = -z;
}
})();
function writeCenterToPacked(index, xFriendly, yFriendly, zFriendly) {
// Convert back to packed space (invert friendly flips)
const x = xFriendly;
const y = -yFriendly;
const z = -zFriendly;
const uX = floatToHalf(x) & 0xffff;
const uY = floatToHalf(y) & 0xffff;
const uZ = floatToHalf(z) & 0xffff;
const i4 = index * 4;
// write X,Y into word1
packed[i4 + 1] = (uY << 16) | uX;
// write Z into low 16 bits of word2, keep the high 16 bits (quat)
packed[i4 + 2] = (packed[i4 + 2] & 0xffff0000) | uZ;
// Update CPU-side cache
centersFriendly[index * 3 + 0] = xFriendly;
centersFriendly[index * 3 + 1] = yFriendly;
centersFriendly[index * 3 + 2] = zFriendly;
}
function bakeClickedRegionToFloor(centerFriendly, radius) {
const r2 = radius * radius;
const floorY = 0.0; // mesh-local floor level
for (let i = 0; i < numSplats; i++) {
const cx = centersFriendly[i * 3 + 0];
const cy = centersFriendly[i * 3 + 1];
const cz = centersFriendly[i * 3 + 2];
const dx = cx - centerFriendly.x;
const dy = cy - centerFriendly.y;
const dz = cz - centerFriendly.z;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 <= r2) {
// Bake to floor: keep x/z, set y to floor
writeCenterToPacked(i, cx, floorY, cz);
}
}
// Upload edited centers to GPU
splatMesh.updateVersion();
}
function resetSplat() {
// Restore original packed centers
const packed = splatMesh.packedSplats.packedArray;
packed.set(originalPacked);
// Rebuild CPU-side centersFriendly from restored packed
for (let i = 0; i < numSplats; i++) {
const i4 = i * 4;
const w1 = packed[i4 + 1];
const w2 = packed[i4 + 2];
const uX = w1 & 0xffff;
const uY = (w1 >>> 16) & 0xffff;
const uZ = w2 & 0xffff;
const x = halfToFloat(uX);
const y = halfToFloat(uY);
const z = halfToFloat(uZ);
centersFriendly[i * 3 + 0] = x;
centersFriendly[i * 3 + 1] = -y;
centersFriendly[i * 3 + 2] = -z;
}
// Clear any interactive holes SDFs
try {
while (interactiveHolesEdit.children.length) interactiveHolesEdit.remove(interactiveHolesEdit.children[0]);
interactiveHolesEdit.sdfs = null;
} catch {}
// Reset impulses and stacking
for (let i = 0; i < MAX_IMPULSES; i++) {
impulses[i].active.value = 0.0;
impulses[i].radius.value = 0.0;
}
impulseWriteIndex = 0;
stackedDepth = 0.0;
lastClickNDC.set(999, 999);
// Upload
splatMesh.updateVersion();
splatMesh.updateGenerator();
}
// Raycaster for clicks (with generous threshold for better hit detection)
const raycaster = new THREE.Raycaster();
// Increase threshold to detect splats more reliably, especially after modifications
raycaster.params.Points = { threshold: 0.5 };
renderer.domElement.addEventListener("pointerdown", (event) => {
const rect = renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObject(splatMesh, false);
const hit = hits && hits.length ? hits[0] : null;
if (!hit) {
console.log("No hit detected at NDC:", ndc, "- try clicking closer to visible splats");
return;
}
console.log("Hit detected at distance:", hit.distance.toFixed(3), "point:", hit.point);
const localPoint = splatMesh.worldToLocal(hit.point.clone());
// Compute local ray direction for depth stacking
const localRayOrigin = splatMesh.worldToLocal(raycaster.ray.origin.clone());
const localRayDir = splatMesh.worldToLocal(raycaster.ray.origin.clone().add(raycaster.ray.direction)).sub(localRayOrigin).normalize();
// Adjust local axes to match visual orientation (model faces camera with X=PI)
localPoint.y = -localPoint.y;
localPoint.z = -localPoint.z;
const adjustedLocalRayDir = new THREE.Vector3(localRayDir.x, -localRayDir.y, -localRayDir.z).normalize();
// Depth stacking: if this click is near previous (in NDC), push deeper
if (ndc.distanceTo(lastClickNDC) < NDC_PROXIMITY) {
stackedDepth += DEPTH_STEP;
} else {
stackedDepth = 0.0;
}
lastClickNDC.copy(ndc);
const depthPoint = localPoint.clone().add(adjustedLocalRayDir.clone().multiplyScalar(stackedDepth));
// Write into circular impulse buffer (persist impulses; never deactivate)
const slot = impulses[impulseWriteIndex];
slot.center.value.copy(depthPoint);
slot.radius.value = uExplosionRadius.value; // use GUI-controlled radius
slot.start.value = animationTime.value; // start time for this impulse
slot.active.value = 1.0;
// Use circular buffer - overwrite oldest when full
impulseWriteIndex = (impulseWriteIndex + 1) % MAX_IMPULSES;
// Bake clicked region to floor on the CPU so raycasting sees updated geometry
bakeClickedRegionToFloor(depthPoint, uExplosionRadius.value);
splatMesh.updateVersion();
});
// Animation loop
renderer.setAnimationLoop((timeMs) => {
const time = timeMs * 0.001;
// Update camera controls
controls.update(camera);
animationTime.value = time;
// Always update; impulses are persistent and unioned in shader
splatMesh.updateVersion();
renderer.render(scene, camera);
});
// Initialize GUI
const gui = new GUI();
const params = {
explosionStrength: uExplosionStrength.value,
explosionRadius: uExplosionRadius.value,
gravity: uGravity.value
};
gui.add(params, "explosionStrength", 0.0, 10.0, 0.1)
.name("Explosion Strength")
.onChange((v) => {
uExplosionStrength.value = v;
});
gui.add(params, "explosionRadius", 0.1, 1.0, 0.05)
.name("Explosion Radius")
.onChange((v) => {
uExplosionRadius.value = v;
});
gui.add(params, "gravity", 0.0, 20.0, 0.1)
.name("Gravity")
.onChange((v) => {
uGravity.value = v;
});
gui.add({ reset: resetSplat }, "reset").name("Reset Interactive Holes");
</script>
</body>
</html>
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>spark | splat-shockwave</title>
<style>
html, body, #canvas { width: 100%; height: 100%; margin: 0; padding: 0; display: block; }
body { background: #000; overflow: hidden; }
.legend {
position: absolute;
top: 20px;
left: 20px;
color: white;
font-family: Arial, sans-serif;
font-size: 16px;
background: rgba(0, 0, 0, 0.7);
padding: 10px 15px;
border-radius: 5px;
pointer-events: none;
z-index: 100;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div class="legend">Click to generate ripples • WASD + Mouse to move camera</div>
<script type="module" src="./main.js"></script>
</body>
</html>
@@ -0,0 +1,156 @@
import {
SparkControls,
SparkRenderer,
SplatMesh,
dyno,
} from "@sparkjsdev/spark";
import * as THREE from "three";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const canvas = document.getElementById("canvas");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
renderer.setClearColor(0x000000, 1);
const scene = new THREE.Scene();
const spark = new SparkRenderer({ renderer });
scene.add(spark);
const camera = new THREE.PerspectiveCamera(
50,
canvas.clientWidth / canvas.clientHeight,
0.01,
2000,
);
camera.position.set(0, 0, 3);
camera.lookAt(0, 0, 0);
scene.add(camera);
function handleResize() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener("resize", handleResize);
// Camera controls with mouse and WASD enabled
const controls = new SparkControls({ canvas: renderer.domElement });
controls.fpsMovement.enable = true; // Enable WASD movement
controls.pointerControls.enable = true; // Enable mouse controls
// Dyno shader with time and shockwave function
function passthroughDyno(timeUniform, hitpointUniform) {
return dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => {
const shader = new dyno.Dyno({
inTypes: {
gsplat: dyno.Gsplat,
time: "float",
hitpoint: "vec3",
},
outTypes: { gsplat: dyno.Gsplat },
globals: () => [
dyno.unindent(`
vec3 shockwave(vec3 center, float t, vec3 hitpoint) {
vec3 direction = center - hitpoint;
float distance = length(direction);
center += normalize(direction)*sin(t*4.-distance*5.)*exp(-t)*smoothstep(t*2.,0.,distance)*.5;
return center;
}
vec4 shockwaveColor(vec4 rgba, vec3 center, float t, vec3 hitpoint) {
vec3 direction = center - hitpoint;
float distance = length(direction);
float wave = sin(t*4.-distance*5.)*exp(-t*.7)*smoothstep(t*2.,0.,distance);
float brightness = pow(abs(wave),3.) * 10.; // Increase brightness on wave crests
rgba.rgb += brightness;
return rgba;
}
`),
],
statements: ({ inputs, outputs }) =>
dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
// Apply shockwave function to position
${outputs.gsplat}.center = shockwave(${inputs.gsplat}.center, ${inputs.time}, ${inputs.hitpoint});
// Apply shockwave function to color
${outputs.gsplat}.rgba = shockwaveColor(${inputs.gsplat}.rgba, ${inputs.gsplat}.center, ${inputs.time}, ${inputs.hitpoint});
`),
});
return {
gsplat: shader.apply({
gsplat,
time: timeUniform,
hitpoint: hitpointUniform,
}).gsplat,
};
},
);
}
async function run() {
// Time and hitpoint uniforms for dyno shader
const timeUniform = dyno.dynoFloat(0.0);
const hitpointUniform = dyno.dynoVec3(new THREE.Vector3(0, 0, 1000)); // Initialize far away to avoid initial effect
// Load valley.spz
const splatURL = await getAssetFileURL("valley.spz");
const valley = new SplatMesh({ url: splatURL });
await valley.initialized;
// Fix orientation - rotate 180 degrees around X axis
valley.rotateX(Math.PI);
// Apply dyno shader with time and hitpoint uniforms
valley.objectModifier = passthroughDyno(timeUniform, hitpointUniform);
valley.updateGenerator();
scene.add(valley);
// Raycaster for click detection
const raycaster = new THREE.Raycaster();
raycaster.params.Points = { threshold: 1.0 }; // Increased threshold for better hit detection
// Simple time counter that resets on click
let timeCounter = 0;
// Click event listener to set hitpoint and reset time
renderer.domElement.addEventListener("pointerdown", (event) => {
const rect = renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObject(valley, false);
const hit = hits?.length ? hits[0] : null;
if (!hit) {
return;
}
const localPoint = valley.worldToLocal(hit.point.clone());
// Don't invert Y or Z - keep original coordinates
hitpointUniform.value.copy(localPoint);
timeCounter = 0; // Reset time counter
});
renderer.setAnimationLoop((timeMs) => {
// Increment time counter each frame
timeCounter += 0.016; // ~60fps increment
timeUniform.value = timeCounter;
// Update dyno uniforms to propagate to the mesh each frame
valley.updateVersion();
controls.update(camera);
renderer.render(scene, camera);
});
}
run();
+75
View File
@@ -0,0 +1,75 @@
// Definition of all the food assets and some attributes for each
export const FOOD_ASSETS = [
{
name: "Branzino Amarin",
file: "branzino-amarin.spz",
scale: 0.3,
shadowSize: 1.0,
},
{
name: "Iberico Sandwich",
file: "iberico-sandwich-by-reserve.spz",
scale: 0.6,
shadowSize: 0,
offsetY: -0.05,
},
{
name: "Coral Caviar",
file: "coral-caviar.spz",
scale: 0.4,
shadowSize: 0.7,
},
{
name: "Pad Thai",
file: "pad-thai.spz",
scale: 0.2,
shadowSize: 1.0,
},
{
name: "Prime Rib",
file: "primerib-tamos.spz",
scale: 0.3,
shadowSize: 1.1,
},
{
name: "Double-Double",
file: "double-double-from-InNOut.spz",
scale: 0.5,
shadowSize: 0.6,
offsetY: 0.03,
},
{
name: "Gyro Kebab",
file: "gyro.spz",
scale: 0.13,
shadowSize: 0,
offsetY: -0.13,
},
{
name: "Mels Steak Sandwich",
file: "steaksandwich-mels.spz",
scale: 0.3,
shadowSize: 0,
offsetY: 0.08,
},
{
name: "Clams and Caviar",
file: "clams-and-caviar-by-ikoyi.spz",
scale: 0.4,
shadowSize: 0.72,
},
{
name: "Tomahawk Niku",
file: "tomahawk-niku.spz",
scale: 0.3,
shadowSize: 1.0,
},
{
name: "Dh Burger",
file: "burger-from-amboy.spz",
scale: 0.4,
shadowSize: 0.6,
offsetY: 0.04,
},
];
export const FOOD_URL = "/examples/assets/splats/food/";
+246
View File
@@ -0,0 +1,246 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Multiple Splats</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<a href="#" id="mobile_button">Menu</a>
<div id="menu">
<div class="border">
<div class="border">
<h3>Tipatat's Splat Restaurant</h3>
<h1> Menu </h1>
<h2>CHEF SPECIALS</h2>
<div id="menu_list"></div>
<h2>Food scans by <a href="https://x.com/tipatat" target="_blank">Tipatat</a></h2>
</div>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SplatMesh } from "@sparkjsdev/spark";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { EXRLoader } from "three/addons/loaders/EXRLoader.js";
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { FOOD_ASSETS, FOOD_URL } from './food.js';
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
import { preloadSplats } from "/examples/js/preloader.js";
// Add food items to the menu
FOOD_ASSETS.forEach((food, i) => {
const el = document.createElement("a");
el.textContent = food.name;
el.href = 'javascript:;';
el.addEventListener('click', async function () {
switchToFood(i);
});
document.getElementById('menu_list').appendChild(el);
});
// Setup mobile menu button
let IS_MOBILE = 'ontouchstart' in window || navigator.msMaxTouchPoints;
document.getElementById('mobile_button').addEventListener('click', () => {
document.getElementById('menu').classList.toggle('visible');
});
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer();
renderer.shadowMap.enabled = true;
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Setup camera
const camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0.9, -1.2);
// handle windows resize
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// Setup mouse controls to orbit the camera around
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0.2, 0, 0);
controls.minDistance = 0.8;
controls.maxDistance = 2.3;
controls.enablePan = false;
controls.update();
// Current and next food splats
let food, nextFood;
// Other items
let table, shadow;
// Add table
const gltfLoader = new GLTFLoader();
const modelURL = await getAssetFileURL("table.glb");
const gltfTable = await gltfLoader.loadAsync(modelURL);
table = gltfTable.scene;
// Transition length in frames.
// Two transitions: one for fading out the old food, another for fading in the next one
const TRANSITION_LENGTH = IS_MOBILE ? 30 : 60;
// Transition timers. `null` if transition is not active
let fadeOutTime = null;
let fadeInTime = null;
// Preload all splat files
let splats;
preloadSplats(FOOD_ASSETS.map(t => t.file)).then(loaded_splats => {
splats = loaded_splats;
init();
});
async function init() {
// Setup lighting
const spotLight = new THREE.SpotLight(0xffcc88);
spotLight.position.set(0, 1, 0);
spotLight.castShadow = true;
spotLight.shadow.mapSize.width = 1024;
spotLight.shadow.mapSize.height = 1024;
spotLight.shadow.camera.near = 0.1;
spotLight.shadow.camera.far = 5;
spotLight.angle = 0.9;
spotLight.penumbra = 1;
spotLight.intensity = 3;
scene.add(spotLight);
const fillLight = new THREE.PointLight(0xffcc88, 0.2);
fillLight.position.set(0, 0, -3);
scene.add(fillLight);
// Splats don't project shadows, so we add a cylinder below the spotlight to fake one ;)
const geometry = new THREE.CylinderGeometry(0.45, 0.45, 0.04, 40, 1);
const material = new THREE.MeshPhongMaterial({ colorWrite: false, depthWrite: false });
shadow = new THREE.Mesh(geometry, material);
shadow.visible = false;
shadow.castShadow = true;
shadow.position.set(0, 0.1, 0);
scene.add(shadow);
// Set the table cloth to receive shadows
const tableCloth = table.children.find(item => item.name == 'cover');
tableCloth.receiveShadow = true;
scene.add(table);
// Add floor
const plane = new THREE.PlaneGeometry(10, 10);
const floormat = new THREE.MeshPhongMaterial({ color: 0x777777 });
const floor = new THREE.Mesh(plane, floormat);
floor.rotation.x = -Math.PI / 2;
floor.position.set(0, -1.397, 0);
scene.add(floor);
// Show menu
document.getElementById('menu').classList.add('visible');
if (IS_MOBILE) document.getElementById('mobile_button').classList.add('visible');
// Load first food by default
switchToFood(0);
// Start render loop
renderer.setAnimationLoop(animate);
}
function animate(time) {
controls.update();
renderer.render(scene, camera);
const rotation = time / 10000;
table.rotation.y = rotation;
if (food) food.rotation.y = -rotation;
if (nextFood) nextFood.rotation.y = -rotation;
// fade out
if (fadeOutTime !== null) {
fadeOutTime++;
if (fadeOutTime < TRANSITION_LENGTH) {
if (food) food.opacity = 1 - easeInOutSine(fadeOutTime / TRANSITION_LENGTH);
} else {
// Fade out finished
if (food) food.dispose();
fadeOutTime = null;
// Fade in next food
fadeInTime = 0;
shadow.visible = true;
shadow.scale.setScalar(shadow.nextScale);
}
}
// fade in
if (fadeInTime != null && nextFood.isInitialized) {
fadeInTime++;
if (fadeInTime < TRANSITION_LENGTH) {
nextFood.opacity = easeInOutSine(fadeInTime / TRANSITION_LENGTH);
} else {
// Fade in finished
food = nextFood;
fadeInTime = null;
}
}
};
// Change food from menu link
function switchToFood(foodIndex) {
// already transitioning
if (fadeOutTime !== null || fadeInTime !== null) return;
const foodItem = FOOD_ASSETS[foodIndex];
nextFood = splats[foodItem.file];
nextFood.quaternion.set(1, 0, 0, 0);
// Customize splat depending on the settings set for this food
if (foodItem['offsetY']) nextFood.position.set(0, foodItem.offsetY, 0);
nextFood.scale.setScalar(foodItem.scale);
shadow.nextScale = foodItem.shadowSize;
// Set opacity to 0 to prepare the fade in transition
nextFood.opacity = 0;
// Setup shadow to the required size, and make it visible only when the splat is initialized
nextFood.initialized.then(() => {
shadow.visible = false;
fadeOutTime = 0; // Fade in next food
});
scene.add(nextFood);
// Hide menu if on mobile
if (IS_MOBILE) document.getElementById('menu').classList.remove('visible');
// toggle menu item
const menu_items = document.getElementById('menu_list').children;
for (let i = 0; i < menu_items.length; i++) {
menu_items[i].classList.toggle('active', foodIndex == i);
}
}
function easeInOutSine(x) {
return -(Math.cos(Math.PI * x) - 1) / 2;
}
</script>
</body>
</html>
+130
View File
@@ -0,0 +1,130 @@
@import url("https://fonts.googleapis.com/css2?family=Mea+Culpa&display=swap");
body {
margin: 0;
background-color: black;
font-family: serif;
}
h1 {
font-family: "Mea Culpa", cursive;
font-weight: 500;
font-size: 5rem;
}
h2 {
font-family: sans-serif;
font-weight: normal;
font-size: 0.8rem;
}
h3 {
font-family: "Mea Culpa", cursive;
margin: 0;
}
#menu {
position: absolute;
left: -12rem;
display: none;
background-color: rgba(223, 203, 171, 0.8);
padding: 2rem 2rem;
margin-top: 4rem;
text-align: center;
font-size: 1.2rem;
line-height: 1.7rem;
left: 1rem;
border-radius: 0.5rem;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.8);
}
#menu .border {
border: 1.0px solid rgb(92, 92, 92);
padding: 1rem 3rem;
}
#menu > .border {
border: 1.5px solid rgb(49, 49, 49);
padding: 5px 6px;
}
#menu a {
font-style: italic;
display: block;
text-decoration: none;
color: rgb(30, 13, 140);
}
#menu a:hover {
/* border-bottom: 1px solid black; */
text-decoration: underline;
}
#menu a.active {
text-decoration: underline;
font-weight: 500;
}
#menu a.active::before {
content: "● ";
font-weight: 100;
}
#menu a.active::after {
content: " ● ";
font-weight: 100;
}
#menu_list {
margin-bottom: 2rem;
}
#menu h2 a {
font-weight: normal;
color: black;
display: inline;
}
#loading {
display: none;
position: absolute;
top: 40vh;
left: 45vw;
}
/* mobile */
#mobile_button {
text-decoration: none;
color: black;
padding: 10px 15px;
background-color: burlywood;
text-align: center;
font-family: serif;
font-style: italic;
font-size: 1.5rem;
position: absolute;
border-radius: 1rem;
margin: 1rem;
opacity: 0.8;
display: none;
}
.visible {
display: block !important;
}
@media (any-pointer: coarse) {
#menu {
display: none;
top: 4.5rem;
left: 0;
font-size: 1rem;
line-height: 3.2vh;
width: 82%;
margin: 0 auto;
}
h1 {
font-size: 4rem;
}
}
+15
View File
@@ -0,0 +1,15 @@
export async function getAssetFileURL(assetFile) {
try {
const response = await fetch("/examples/assets.json");
const assetsDirectory = "/examples/assets/";
const assetsInfo = await response.json();
let url = assetsInfo[assetFile].url;
if (window.sparkLocalAssets) {
url = `${assetsDirectory}${assetsInfo[assetFile].directory}/${assetFile}`;
}
return url;
} catch (error) {
console.error("Failed to load asset file URL:", error);
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { SplatMesh } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
// Add loading icon in the center of the screen
function addLoader(loading_icon_color) {
const loaderIcon = new Image();
loaderIcon.src = `/examples/js/spark_loading_${loading_icon_color}.gif`;
loaderIcon.id = "_spark_loader";
loaderIcon.style.position = "absolute";
loaderIcon.style.zIndex = 99999;
loaderIcon.style.left = "50%";
loaderIcon.style.top = "50%";
loaderIcon.style.transform = "translate(-50%, -50%)";
loaderIcon.style.pointerEvents = "none";
document.body.appendChild(loaderIcon);
}
function removeLoader() {
const el = document.getElementById("_spark_loader");
el.parentNode.removeChild(el);
}
// preload splats, returning a map [filename -> SplatMesh]
export async function preloadSplats(assets, loading_icon_color = "white") {
addLoader(loading_icon_color);
const map = {};
await Promise.all(
assets.map(async (asset) => {
const splatURL = await getAssetFileURL(asset);
return new Promise((resolve) => {
map[asset] = new SplatMesh({
url: splatURL,
onLoad: () => resolve(),
});
});
}),
);
removeLoader();
return map;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Multiple Splats</title>
<style>
body {
margin: 0;
}
a,
footer {
color: #797979
}
footer {
position: absolute;
margin: 10px;
bottom: 0;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SplatMesh, PackedSplats } from "@sparkjsdev/spark";
import { EXRLoader } from "three/addons/loaders/EXRLoader.js";
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 6.5);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(new THREE.Color(0xFFFFFF), 1);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
let splatURL = await getAssetFileURL("butterfly-ai.spz");
const butterflySplats = new PackedSplats({ url: splatURL });
const butterflies = [];
for (let i = 0; i < 6; i++) {
const splat = new SplatMesh({ packedSplats: butterflySplats });
splat.quaternion.set(1, 0, 0, 0);
scene.add(splat);
butterflies.push(splat);
}
splatURL = await getAssetFileURL("cat.spz");
const cat = new SplatMesh({ url: splatURL });
cat.quaternion.set(1, 0, 0, 0);
cat.scale.setScalar(0.5);
scene.add(cat);
// Setup mouse controls to orbit the camera around
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.minDistance = 0.3;
controls.maxDistance = 20;
controls.update();
const RADIUS = 2;
renderer.setAnimationLoop(function animate(time) {
controls.update();
// Rotate food
for (let i = 0; i < butterflies.length; i++) {
const ang = (-time / 10000) + i / butterflies.length * Math.PI * 2;
butterflies[i].position.set(Math.cos(ang) * RADIUS, Math.sin(ang) * RADIUS - 0.2, 0);
butterflies[i].rotation.y = i + time / 4000;
}
// Animate food master
cat.position.y = -0.8 + Math.sin(time / 1000) * 0.1;
renderer.render(scene, camera);
});
</script>
</body>
</html>
@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Multiple Viewpoints</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<input type="range" min="-100" max="100" id="camera1">
<input type="range" min="-100" max="100" id="camera2">
</header>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SparkRenderer, SparkViewpoint, SplatMesh } from "@sparkjsdev/spark";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
const header_height = document.querySelector('header').getBoundingClientRect().height;
renderer.setSize(window.innerWidth, window.innerHeight - header_height);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// Explicitly create a SparkRenderer in the scene to spawn new viewpoints
const spark = new SparkRenderer({ renderer });
scene.add(spark);
const splatURL = await getAssetFileURL("butterfly.spz");
const butterfly = new SplatMesh({ url: splatURL });
butterfly.quaternion.set(1, 0, 0, 0);
butterfly.position.set(0, 0, -3);
scene.add(butterfly);
async function createViewpoint(origin, screenPos, index) {
// Create a textured rectangle at the near plane to show the viewpoint
const width = 320;
const height = 240;
const screen = new THREE.Mesh(
new THREE.PlaneGeometry(0.5 * camera.near, 0.5 * camera.near * height / width),
new THREE.MeshBasicMaterial({ map: SparkViewpoint.EMPTY_TEXTURE }),
);
screen.position.set(screenPos.x, screenPos.y, -1).multiplyScalar(camera.near);
scene.add(screen);
const viewCamera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
viewCamera.position.copy(origin);
viewCamera.lookAt(butterfly.position);
// Load arrow object to show where each camera is
const arrowURL = await getAssetFileURL("arrow.glb");
new GLTFLoader().load(arrowURL, gltf => {
// Different material for each camera
const arrowMaterial = new THREE.MeshMatcapMaterial({
matcap: [
new THREE.TextureLoader().load("https://raw.githubusercontent.com/nidorx/matcaps/master/128/1A2461_3D70DB_2C3C8F_2C6CAC-128px.png"),
new THREE.TextureLoader().load("https://raw.githubusercontent.com/nidorx/matcaps/master/128/9D282A_38191D_DFC6CD_D6495A-128px.png")
][index]
});
const arrow = gltf.scene.children[0];
arrow.material = arrowMaterial;
arrow.position.copy(viewCamera.position);
arrow.lookAt(butterfly.position);
scene.add(arrow);
// Connect slider to both camera and arrow
document.getElementById("camera" + (index + 1)).oninput = ev => {
viewCamera.position.x = (ev.target.value / 100);
arrow.position.x = viewCamera.position.x;
arrow.lookAt(butterfly.position);
};
});
// Spawn a new viewpoint that auto-updates its sort order, double
// buffered in case the texture is used recursively in the scene
const viewpoint = spark.newViewpoint({
autoUpdate: true,
camera: viewCamera,
target: { width, height, doubleBuffer: true },
onTextureUpdated: (texture) => {
// Update the view screen with the rendered viewpoint
screen.material.map = texture;
},
});
return viewpoint;
}
const viewpoints = [
await createViewpoint(new THREE.Vector3(0, 1.5, -3), new THREE.Vector2(-0.5, 0.5), 0),
await createViewpoint(new THREE.Vector3(0.5, -1, -3), new THREE.Vector2(0.5, -0.5), 1),
];
let lastTime;
renderer.setAnimationLoop(function animate(time) {
const deltaTime = time - (lastTime || time);
lastTime = time;
scene.background.set(0x000000);
renderer.render(scene, camera);
butterfly.rotation.y += deltaTime / 2500;
// Render each viewpoint (with a different background each)
for (let i = 0; i < viewpoints.length; i++) {
scene.background.set([0x202040, 0x302020][i]);
viewpoints[i].renderTarget({ scene, camera: viewpoints[i].camera });
}
});
</script>
</body>
</html>
@@ -0,0 +1,94 @@
body {
margin: 0;
background-color: black;
}
header {
margin: 0 auto;
padding: 10px 0;
min-width: 300px;
width: 50%;
}
/********** Range Input Styles **********/
/*Range Reset*/
input {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
width: 100%;
padding: 10px 0;
}
/* Removes default focus */
input:focus {
outline: none;
}
/***** Chrome, Safari, Opera and Edge Chromium styles *****/
/* slider track */
input::-webkit-slider-runnable-track {
background-color: #4a4a4a;
border-radius: 0.5rem;
height: 0.3rem;
margin-top: 1.2rem;
}
/* slider thumb */
input::-webkit-slider-thumb {
-webkit-appearance: none;
/* Override default look */
appearance: none;
margin-top: -9px;
/* Centers thumb on the track */
/*custom styles*/
background-color: #e5e5e5;
height: 1.4rem;
width: 1.4rem;
border-radius: 1rem;
border: none;
}
#camera1::-webkit-slider-runnable-track {
background-color: #223165;
}
#camera1::-webkit-slider-thumb {
background-color: #4967c9;
}
#camera2::-webkit-slider-runnable-track {
background-color: #5c2424;
}
#camera2::-webkit-slider-thumb {
background-color: #c44b4b;
}
/******** Firefox styles ********/
/* slider track */
input::-moz-range-track {
border-radius: 0.5rem;
height: 0.3rem;
opacity: 0.5;
}
/* slider thumb */
input::-moz-range-thumb {
height: 1.4rem;
width: 1.4rem;
border-radius: 1rem;
border: none;
}
#camera2::-moz-range-track,
#camera2::-moz-range-thumb {
background-color: #c44b4b;
}
#camera1::-moz-range-track,
#camera1::-moz-range-thumb {
background-color: #4967c9;
}
@@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Particle Animation</title>
<style>
html, body {
margin: 0;
height: 100%;
width: 100%;
background-color: black;
}
#canvas {
position: absolute;
width: 100%;
height: 100%;
touch-action: none;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import {
SparkRenderer,
PointerControls,
SplatMesh,
VRButton,
} from "@sparkjsdev/spark";
import GUI from "lil-gui";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
// cloud bounds
const BOUNDS = {
MIN_X: -8,
MAX_X: 8,
MIN_Y: -0.33,
MAX_Y: 0.33,
MIN_Z: -8,
MAX_Z: 10,
};
// cloud appearance parameters
const params = {
windSpeed: -0.3,
particleCount: 20000,
opacity: 0.5,
fluffiness: 0.5,
turbulence: 0.5,
cloudDensity: 0.7,
color1: new THREE.Color(1.0, 1.0, 1.0),
color2: new THREE.Color(0.87, 0.87, 0.87),
octaves: 4,
frequency: 0.3,
amplitude: 0.5,
lacunarity: 2.0,
persistence: 0.5,
phase: 0.1,
};
const presets = {
"Partially Cloudy": {
particleCount: 10000,
color1: new THREE.Color(1.0, 1.0, 1.0),
color2: new THREE.Color(0.87, 0.87, 0.87),
opacity: 0.3,
windSpeed: -0.5,
fluffiness: 0.5,
turbulence: 0.5,
cloudDensity: 0.5,
},
Cloudy: {
particleCount: 20000,
color1: new THREE.Color(1.0, 1.0, 1.0),
color2: new THREE.Color(0.87, 0.87, 0.87),
opacity: 0.5,
windSpeed: -0.3,
fluffiness: 0.5,
turbulence: 0.5,
cloudDensity: 0.7,
},
Storm: {
particleCount: 30000,
color1: new THREE.Color(0.66, 0.66, 0.66),
color2: new THREE.Color(0.44, 0.44, 0.44),
opacity: 0.7,
windSpeed: -0.7,
fluffiness: 1.0,
turbulence: 0.8,
cloudDensity: 1.0,
},
};
// fBM noise implementation
function noise(x, y, z, t, noiseParams) {
let value = 0;
let amp = noiseParams.amplitude;
let freq = noiseParams.frequency;
for (let i = 0; i < noiseParams.octaves; i++) {
const to = t * noiseParams.phase * (i + 1);
// 3D grid of sines
value +=
amp *
Math.sin(x * freq + to) *
Math.sin(y * freq + to) *
Math.sin(z * freq + to);
freq *= noiseParams.lacunarity;
amp *= noiseParams.persistence;
}
return value;
}
function wrap(val, min, max) {
const range = max - min;
return ((((val - min) % range) + range) % range) + min;
}
// create splats from current params
function createClouds(splats, particleCount) {
const topColor = new THREE.Color().copy(params.color1);
const bottomColor = new THREE.Color().copy(params.color2);
const center = new THREE.Vector3();
const scales = new THREE.Vector3(0.12, 0.08, 0.12);
const quaternion = new THREE.Quaternion();
const color = new THREE.Color();
const now = performance.now() * 0.001 * 0.2;
for (let i = 0; i < particleCount; i++) {
// Generate consistent randomness per particle
// (prevents flickering during recreation)
// based on common rand() one-liner
// see https://web.archive.org/web/20080211204527/http://lumina.sourceforge.net/Tutorials/Noise.html
const seed = i * 0.12345;
const random = new THREE.Vector4(
(Math.sin(seed * 12.9898) * 43758.5453) % 1,
(Math.sin(seed * 78.233) * 43758.5453) % 1,
(Math.sin(seed * 37.719) * 43758.5453) % 1,
(Math.sin(seed * 93.989) * 43758.5453) % 1,
)
.multiplyScalar(0.5)
.addScalar(0.5);
// Set base positions within bounds
let x = THREE.MathUtils.lerp(BOUNDS.MIN_X, BOUNDS.MAX_X, random.x);
let y = THREE.MathUtils.lerp(BOUNDS.MIN_Y, BOUNDS.MAX_Y, random.y);
let z = THREE.MathUtils.lerp(BOUNDS.MIN_Z, BOUNDS.MAX_Z, random.z);
// apply vertical variation (fluffiness)
const fluffiness =
Math.sin(random.w * Math.PI * 2) * params.fluffiness * 0.5;
y += fluffiness;
// apply horizontal variation (turbulence)
const turbulence =
Math.sin(random.w * Math.PI * 8) * params.turbulence * 0.3;
x += turbulence;
// Add fBM noise
const n = noise(x, y, z, now, params) * params.fluffiness * 0.1;
y += n;
// Color gradient based on normalized height
const t = THREE.MathUtils.clamp(
(y - BOUNDS.MIN_Y) / (BOUNDS.MAX_Y - BOUNDS.MIN_Y),
0,
1,
);
color.copy(bottomColor).lerp(topColor, t);
x = wrap(x, BOUNDS.MIN_X, BOUNDS.MAX_X);
y = wrap(y, BOUNDS.MIN_Y, BOUNDS.MAX_Y);
z = wrap(z, BOUNDS.MIN_Z, BOUNDS.MAX_Z);
const opacity = Math.max(
0.1,
params.opacity * (0.8 + 0.4 * Math.abs(fluffiness)),
);
center.set(x, y, z);
splats.pushSplat(center, scales, quaternion, opacity, color);
}
}
// animate splats within bounds
function createCloudAnimation(params, BOUNDS) {
return ({ mesh, time, deltaTime }) => {
mesh.packedSplats.forEachSplat(
(index, center, scales, quaternion, opacity, color) => {
const seed = index * 0.12345;
const randomZ = (Math.sin(seed * 37.719) * 43758.5453) % 1;
const zZero = THREE.MathUtils.lerp(
BOUNDS.MIN_Z,
BOUNDS.MAX_Z,
Math.abs(randomZ),
);
const displacement = params.windSpeed * time;
const range = BOUNDS.MAX_Z - BOUNDS.MIN_Z;
let newZ = zZero + displacement;
newZ =
((((newZ - BOUNDS.MIN_Z) % range) + range) % range) + BOUNDS.MIN_Z;
center.set(center.x, center.y, newZ);
mesh.packedSplats.setSplat(
index,
center,
scales,
quaternion,
opacity,
color,
);
},
);
mesh.packedSplats.needsUpdate = true;
mesh.needsUpdate = true;
};
}
// creates splat mesh from cloud params
function createCloudMesh(params, BOUNDS) {
const actualParticleCount = Math.floor(
params.particleCount * params.cloudDensity,
);
return new SplatMesh({
maxSplats: actualParticleCount,
constructSplats: (splats) => {
createClouds(splats, actualParticleCount);
},
onFrame: createCloudAnimation(params, BOUNDS),
});
}
// setup scene + environment
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
scene.background = new THREE.Color(0x08a2d3);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const spark = new SparkRenderer({ renderer });
scene.add(spark);
function handleResize() {
const width = window.innerWidth;
const height = window.innerHeight;
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
handleResize();
window.addEventListener("resize", handleResize);
// add sky background
const skyGeometry = new THREE.SphereGeometry(10, 60, 40);
const skyMaterial = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load((await getAssetFileURL("sky.jpeg"))),
side: THREE.BackSide,
depthWrite: false,
});
const skySphere = new THREE.Mesh(skyGeometry, skyMaterial);
skySphere.scale.set(-1, 1, 1);
skySphere.renderDepth = 1000.0;
scene.add(skySphere);
camera.position.set(0, 0, 0);
skySphere.position.copy(camera.position);
const controls = new PointerControls({ canvas: renderer.domElement });
// create initial clouds on first load
let clouds = createCloudMesh(params, BOUNDS);
clouds.position.set(0, -0.5, -2);
scene.add(clouds);
function recreateClouds() {
if (clouds) {
scene.remove(clouds);
clouds.dispose();
}
clouds = createCloudMesh(params, BOUNDS);
clouds.position.set(0, -0.5, -2);
scene.add(clouds);
}
// cloud parameter controls
const gui = new GUI({ title: "Clouds" });
gui.add(params, "windSpeed", -1, 1, 0.1).name("Wind Speed");
gui
.add(params, "opacity", 0.05, 1, 0.01)
.name("Thickness")
.onChange(recreateClouds);
gui
.add(params, "fluffiness", 0, 2, 0.01)
.name("Fluffiness")
.onChange(recreateClouds);
gui
.add(params, "turbulence", 0, 1, 0.01)
.name("Turbulence")
.onChange(recreateClouds);
gui
.add(params, "cloudDensity", 0.1, 1, 0.01)
.name("Cloud Density")
.onChange(recreateClouds);
// advanced noise params
const advancedFolder = gui.addFolder("Advanced");
advancedFolder
.add(params, "octaves", 1, 8, 1)
.name("Noise Octaves")
.onChange(recreateClouds);
advancedFolder
.add(params, "frequency", 0.1, 1, 0.01)
.name("Noise Frequency")
.onChange(recreateClouds);
const presetsFolder = gui.addFolder("Weather");
for (const [name, preset] of Object.entries(presets)) {
presetsFolder.add(
{
[name]: () => {
Object.assign(params, preset);
recreateClouds();
for (const controller of gui.controllersRecursive()) {
controller.updateDisplay();
}
},
},
name,
);
}
let lastTime = 0;
renderer.setAnimationLoop(function animate(time) {
const deltaTime = (time - lastTime) * 0.001;
lastTime = time;
controls.update(deltaTime, camera);
renderer.render(scene, camera);
});
</script>
</body>
</html>
@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Particle Simulation</title>
<style>
html, body {
margin: 0;
height: 100%;
width: 100%;
background-color: black;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import {
SparkRenderer,
PointerControls,
SplatMesh,
generators,
} from "@sparkjsdev/spark";
import GUI from "lil-gui";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const SCENE_CONFIG = {
SNOW_HEIGHT: 8,
HALF_WIDTH: 12,
HALF_DEPTH: 48,
SNOW_MIN_Y: -4,
CAMERA_DURATION: 90,
};
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const spark = new SparkRenderer({ renderer });
scene.add(spark);
function handleResize() {
const width = window.innerWidth;
const height = window.innerHeight;
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
handleResize();
window.addEventListener("resize", handleResize);
const backgroundURL = await getAssetFileURL("snow-street.spz");
const background = new SplatMesh({ url: backgroundURL });
background.quaternion.set(1, 0, 0, 0);
background.position.set(
background.position.x - 8,
background.position.y - 12,
background.position.z - 104,
);
scene.add(background);
await background.initialized;
const controls = new PointerControls({ canvas: renderer.domElement });
const cam = camera.position;
const snowVolumeBox = new THREE.Box3(
new THREE.Vector3(
cam.x - SCENE_CONFIG.HALF_WIDTH,
cam.y - (SCENE_CONFIG.SNOW_HEIGHT * 8),
cam.z - SCENE_CONFIG.HALF_DEPTH * 2,
),
new THREE.Vector3(
cam.x + SCENE_CONFIG.HALF_WIDTH,
cam.y + SCENE_CONFIG.SNOW_HEIGHT * 2,
cam.z + SCENE_CONFIG.HALF_DEPTH / 128,
),
);
const snow = generators.snowBox({
...generators.DEFAULT_SNOW,
box: snowVolumeBox.clone(),
minY: SCENE_CONFIG.SNOW_MIN_Y,
color1: new THREE.Color(1.0, 1.0, 1.0),
color2: new THREE.Color(0.91, 0.9, 0.97),
density: 1000,
maxScale: 0.01,
});
scene.add(snow.snow);
const gui = new GUI({ title: "Snow Particle Simulation" });
const params = {
fallVelocity: 0.04,
wanderScale: generators.DEFAULT_SNOW.wanderScale,
wanderVariance: generators.DEFAULT_SNOW.wanderVariance,
maxScale: 0.01,
color1: new THREE.Color(1.0, 1.0, 1.0),
color2: new THREE.Color(0.91, 0.9, 0.97),
isPaused: false,
fallDirection: new THREE.Vector3(0, -1, 0),
windDirection: "No Direction",
_previousFallVelocity: generators.DEFAULT_SNOW.fallVelocity,
_previousWanderScale: generators.DEFAULT_SNOW.wanderScale,
_previousWanderVariance: generators.DEFAULT_SNOW.wanderVariance,
};
// snow parameter controls
gui.add(params, "fallVelocity", 0.01, 0.5, 0.01).name("Fall Velocity");
gui.add(params, "wanderScale", 0.01, 1, 0.01).name("Wander Scale");
gui.add(params, "wanderVariance", 1, 5, 0.1).name("Wander Variance");
gui.add(params, "maxScale", 0.001, 0.05, 0.001).name("Snow Size");
gui
.add(params, "isPaused")
.name("Pause Snowfall")
.onChange((value) => {
if (value) {
params._previousFallVelocity = params.fallVelocity;
params._previousWanderScale = params.wanderScale;
params._previousWanderVariance = params.wanderVariance;
snow.fallVelocity.value = 0;
snow.wanderScale.value = 0;
snow.wanderVariance.value = 0;
} else {
snow.fallVelocity.value = params._previousFallVelocity;
snow.wanderScale.value = params._previousWanderScale;
snow.wanderVariance.value = params._previousWanderVariance;
}
});
// color
const colorFolder = gui.addFolder("Snow Colors");
colorFolder
.addColor(params, "color1")
.onChange((value) => {
snow.color1.value.set(value.r, value.g, value.b);
})
.name("Color 1");
colorFolder
.addColor(params, "color2")
.onChange((value) => {
snow.color2.value.set(value.r, value.g, value.b);
})
.name("Color 2");
// wind
const directionFolder = gui.addFolder("Wind Orientation");
directionFolder
.add(params.fallDirection, "x", -1, 1, 0.1)
.onChange(() => {
params.fallDirection.normalize();
snow.fallDirection.value.copy(params.fallDirection);
})
.name("East-West");
directionFolder
.add(params.fallDirection, "z", -1, 1, 0.1)
.onChange(() => {
params.fallDirection.normalize();
snow.fallDirection.value.copy(params.fallDirection);
})
.name("North-South");
const windFolder = gui.addFolder("Wind Direction Presets");
windFolder
.add(params, "windDirection", [
"N",
"NE",
"E",
"SE",
"No Direction",
"S",
"SW",
"W",
"NW",
])
.onChange((value) => {
const directions = {
N: new THREE.Vector3(0, -1, -1),
NE: new THREE.Vector3(1, -1, -1),
E: new THREE.Vector3(1, -1, 0),
SE: new THREE.Vector3(1, -1, 1),
"No Direction": new THREE.Vector3(0, -1, 0),
S: new THREE.Vector3(0, -1, 1),
SW: new THREE.Vector3(-1, -1, 1),
W: new THREE.Vector3(-1, -1, 0),
NW: new THREE.Vector3(-1, -1, -1),
};
params.fallDirection.copy(directions[value]).normalize();
snow.fallDirection.value.copy(params.fallDirection);
})
.name("Wind Direction");
let lastTime;
renderer.setAnimationLoop(function animate(time) {
const t = time * 0.001;
const dt = t - (lastTime ?? t);
lastTime = t;
controls.update(dt, camera);
// camera movement
const targetZ = -13.5;
const startZ = 0;
const duration = 90;
const progress = Math.min(1, t / duration);
const smoothProgress = Math.sin(progress * Math.PI * 0.5);
camera.position.z = startZ + (targetZ - startZ) * smoothProgress;
if (!params.isPaused) {
snow.fallVelocity.value = params.fallVelocity;
snow.wanderScale.value = params.wanderScale;
snow.wanderVariance.value = params.wanderVariance;
snow.maxScale.value = params.maxScale;
}
renderer.render(scene, camera);
});
</script>
</body>
</html>
@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Procedural Splats</title>
<style>
body {
margin: 0;
}
canvas {
touch-action: none;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SparkControls, SplatMesh, constructGrid, constructAxes, textSplats, imageSplats } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// Generate a Sierpinski pyramid one splat at a time via iteration
const pyramid = new SplatMesh({
constructSplats: (splats) => {
const NUM_SPLATS = 500000;
const ITERATIONS = 15;
const CORNERS = [
new THREE.Vector3(0, 1, -0.7),
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0.6, 1, 0.5),
new THREE.Vector3(-0.6, 1, 0.5),
];
const center = new THREE.Vector3();
const scales = new THREE.Vector3().setScalar(0.0005);
const quaternion = new THREE.Quaternion();
const opacity = 1;
const color = new THREE.Color(1, 1, 1);
for (let i = 0; i < NUM_SPLATS; i++) {
center.set(0, 0, 0);
for (let iter = 0; iter < ITERATIONS; iter++) {
const corner = CORNERS[Math.floor(Math.random() * 4)];
center.add(corner).multiplyScalar(0.5);
}
color.set(0.3 + center.x - center.z * 0.5, center.z * 1.5, 0.5 + center.z * 2);
splats.pushSplat(center, scales, quaternion, opacity, color);
}
},
});
pyramid.position.set(0, -0.5, -2);
scene.add(pyramid);
const stars = new SplatMesh({
constructSplats: (splats) => {
const NUM_SPLATS = 100000;
const center = new THREE.Vector3();
const scales = new THREE.Vector3().setScalar(0.008);
const quaternion = new THREE.Quaternion();
const opacity = 1;
const color = new THREE.Color(1, 1, 1);
for (let i = 0; i < NUM_SPLATS; i++) {
// Distribute stars on a 50x50x50 cube
center.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).multiplyScalar(50);
// Random purple-ish color
color.set(0.4 + Math.random() * 0.3, 0.5, 0.7 + Math.random() * 0.3);
splats.pushSplat(center, scales, quaternion, opacity, color);
}
},
});
stars.position.set(0, -0.5, -2);
scene.add(stars);
// Generate splats from rendered text
const text = textSplats({
text: "I GSplats!",
font: "Arial",
fontSize: 60,
color: new THREE.Color(0xE420B7),
});
text.scale.setScalar(0.5 / 80);
text.position.set(0.15, -1, -2);
scene.add(text);
const text2 = textSplats({
text: "WASD + mouse to move",
font: "Verdana",
fontSize: 30,
color: new THREE.Color(0x49EAFF)
});
text2.scale.setScalar(0.2 / 40);
text2.position.set(0, 1.0, -2);
scene.add(text2);
// Generate splats from image pixels with alpha channel
const imageURL = await getAssetFileURL("butterfly.png");
const image = imageSplats({ url: imageURL });
image.scale.setScalar(0.7 / 400);
image.position.set(-0.5, -1, -2);
scene.add(image);
const controls = new SparkControls({ canvas: renderer.domElement });
renderer.setAnimationLoop(function animate(time) {
controls.update(camera);
renderer.render(scene, camera);
pyramid.rotation.y = time / 5000;
// Move stars back and forth
stars.position.z = Math.sin(time / 10000) * 10;
// Rotate butterfly at different rates on each axis
image.rotation.set(
-0.3 + 0.35 * Math.sin(time / 190),
0.35 * Math.sin(time / 390),
0.1 * Math.sin(time / 200)
);
// Oscilate butterfly y position
image.position.y = -0.9 + 0.04 * Math.sin(time / 330);
// Animate opacity of top text
text2.opacity = Math.abs(Math.sin(time / 1000) * 0.3);
});
</script>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Raycasting</title>
<style>
body {
margin: 0;
}
header {
position: absolute;
color: silver;
font-family: sans-serif;
padding-top: 20px;
text-align: center;
width: 100vw;
}
</style>
</head>
<body>
<header>Click to select</header>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SplatMesh, PackedSplats } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 10);
camera.position.set(0, -0.25, -1.5);
camera.lookAt(0, -0.15, 0);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// Add robots
const NUM_ROBOTS = 5;
const robots = [];
const splatURL = await getAssetFileURL("robot-head.spz");
const packedSplats = new PackedSplats({ url: splatURL });
for (let i = 0; i < NUM_ROBOTS; i++) {
const robot = new SplatMesh({ packedSplats });
robot.rotation.x = Math.PI;
robot.scale.setScalar(0.2);
robot.position.set(0, 0, i);
robot.speed = 600 + i * 10;
robot.angle = 0;
robot.clickedTimer = false;
scene.add(robot);
robots.push(robot);
}
// Raycast logic
const raycaster = new THREE.Raycaster();
renderer.domElement.addEventListener("click", (event) => {
const clickCoords = new THREE.Vector2(
(event.clientX / renderer.domElement.width) * 2 - 1,
-(event.clientY / renderer.domElement.height) * 2 + 1,
);
// Run raycaster
raycaster.setFromCamera(clickCoords, camera);
const hits = raycaster.intersectObjects(scene.children);
// Find first SplatMesh
const index = hits.findIndex(hit => hit.object instanceof SplatMesh);
if (index >= 0) {
const robot = hits[index].object;
if (!robot.clickedTimer) {
// Tint object and start countdown timer to remove tint
robot.recolor.set(1, 0.5, 0.5);
robot.clickedTimer = 40;
}
}
});
// Main loop
renderer.setAnimationLoop(function animate(time) {
// Animate all robots, and check if it's time to remove tint
for (let robot of robots) {
// Pendulum movement
robot.position.x = Math.cos(robot.angle) * 0.6;
robot.position.y = Math.sin(robot.angle) * 0.6;
robot.angle = Math.PI * 1.5 + Math.sin(time / robot.speed);
if (robot.clickedTimer > 0) {
robot.clickedTimer -= 1;
if (robot.clickedTimer == 0) {
// Clicked timer expired, remove tint
robot.recolor.set(1, 1, 1);
}
}
}
renderer.render(scene, camera);
});
</script>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • SOGS</title>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { Sky } from 'three/addons/objects/Sky.js';
import { SplatMesh } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.01, 1000);
camera.position.set(0, 1.5, -1.2);
const renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
const splatURL = await getAssetFileURL("sutro.zip");
const sutroTower = new SplatMesh({ url: splatURL });
sutroTower.quaternion.set(1, 0, 0, 0);
scene.add(sutroTower);
const sky = new Sky();
sky.scale.setScalar(450000);
const phi = THREE.MathUtils.degToRad(20);
const theta = THREE.MathUtils.degToRad(90);
const sunPosition = new THREE.Vector3().setFromSphericalCoords(1, phi, theta);
sky.material.uniforms.sunPosition.value = sunPosition;
scene.add( sky );
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.5, 0);
controls.minDistance = 0.2;
controls.maxDistance = 8.0;
controls.enablePan = false;
renderer.setAnimationLoop(function animate(time) {
controls.update(camera);
renderer.render(scene, camera);
});
</script>
</body>
</html>
+259
View File
@@ -0,0 +1,259 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Spark • Splat Flow</title>
<style>
html, body { margin: 0; height: 100%; width: 100%; background-color: black; }
#canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; outline: none; touch-action: none; }
</style>
</head>
<body>
<canvas id="canvas" tabindex="0"></canvas>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import { dyno, SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
import { GUI } from "lil-gui";
const splatFiles = ["woobles.spz", "dessert.spz", "robot-head.spz"];
const skyFile = "dali-env.glb";
const PARAMETERS = { speedMultiplier: 1.0, objectRotation: true, pause: false, fixedMinScale: false, waves: 0.5, cameraRotation: true };
const PAUSE_SECONDS = 2.0;
function getTransitionState(t, fadeInTime, fadeOutTime, period) {
const one = dyno.dynoFloat(1.0);
const pauseTime = dyno.dynoFloat(PAUSE_SECONDS);
const cycleTime = dyno.add(one, pauseTime);
const total = dyno.mul(period, cycleTime);
const wrapT = dyno.mod(t, total);
const pos = dyno.mod(wrapT, cycleTime);
const inPause = dyno.greaterThan(pos, one);
const normT = dyno.select(inPause, one, pos);
const fadeIn = dyno.and(
dyno.greaterThan(wrapT, dyno.mul(fadeInTime, cycleTime)),
dyno.lessThan(wrapT, dyno.mul(dyno.add(fadeInTime, one), cycleTime))
);
const fadeOut = dyno.and(
dyno.greaterThan(wrapT, dyno.mul(fadeOutTime, cycleTime)),
dyno.lessThan(wrapT, dyno.mul(dyno.add(fadeOutTime, one), cycleTime))
);
return { inTransition: dyno.or(fadeIn, fadeOut), isFadeIn: fadeIn, normT };
}
function contractionDyno(centerGLSL) {
return new dyno.Dyno({
inTypes: { gsplat: dyno.Gsplat, inTransition: "bool", fadeIn: "bool", t: "float", gt: "float", objectIndex: "int", fixedMinScale: "bool", waves: "float" },
outTypes: { gsplat: dyno.Gsplat },
globals: () => [ dyno.unindent(`
float hash13(vec3 p3) { p3 = fract(p3 * .1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
float hash11(float p) { p = fract(p * .1031); p += dot(p, p + 33.33); return fract(p * p); }
float fadeInOut(float t) { return abs(mix(-1., 1., t)); }
${centerGLSL}
float applyBrightness(float t) { return .5 + fadeInOut(t) * .5; }
vec3 applyCenter(vec3 center, float t, float id, int idx, float waves) {
int next = (idx + 1) % 3;
vec3 cNext = getCenterOfMass(next);
vec3 cOwn = getCenterOfMass(idx);
float f = fadeInOut(t);
float v = .5 + hash11(id) * 2.;
vec3 p = t < .5 ? mix(cNext, center, pow(f, v)) : mix(cOwn, center, pow(f, v));
return p + length(sin(p*2.5)) * waves * (1.-f)*smoothstep(0.5,0.,t) * 2.;
}
vec3 applyScale(vec3 s, float t, bool fixedMin) { return mix(fixedMin ? vec3(.02) : s * .2, s, pow(fadeInOut(t), 3.)); }
float applyOpacity(float t, float gt, int idx) {
float p = float(${PAUSE_SECONDS});
float c = 1.0 + p;
float tot = 3.0 * c;
float w = mod(gt + p + .5, tot);
int cur = int(floor(w / c));
return cur == idx ? .1+fadeInOut(t) : 0.0;
}
`) ],
statements: ({ inputs, outputs }) => dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
${outputs.gsplat}.center = applyCenter(${inputs.gsplat}.center, ${inputs.t}, float(${inputs.gsplat}.index), ${inputs.objectIndex}, ${inputs.waves});
${outputs.gsplat}.scales = applyScale(${inputs.gsplat}.scales, ${inputs.t}, ${inputs.fixedMinScale});
${outputs.gsplat}.rgba.a *= applyOpacity(${inputs.t}, ${inputs.gt}, ${inputs.objectIndex});
${outputs.gsplat}.rgba.rgb *= applyBrightness(${inputs.t});
`)
});
}
function getTransitionModifier(inTrans, fadeIn, t, idx, gt, centerGLSL, fixedMinScale, waves) {
const dyn = contractionDyno(centerGLSL);
return dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => ({ gsplat: dyn.apply({ gsplat, inTransition: inTrans, fadeIn, t, gt, objectIndex: idx, fixedMinScale, waves }).gsplat })
);
}
async function loadGLB(file, isEnv = false) {
const url = await getAssetFileURL(file);
const loader = new GLTFLoader();
const gltf = await new Promise((res, rej) => loader.load(url, res, undefined, rej));
gltf.scene.traverse(child => {
if (child.isMesh && child.material) {
const mat = new THREE.MeshBasicMaterial({ color: child.material.color, map: child.material.map });
if (isEnv) {
mat.side = THREE.BackSide;
mat.map.mapping = THREE.EquirectangularReflectionMapping;
mat.map.colorSpace = THREE.LinearSRGBColorSpace;
mat.map.needsUpdate = true;
}
child.material = mat;
}
});
return gltf.scene;
}
const canvas = document.getElementById("canvas");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
renderer.setClearColor(0x000000, 1);
const scene = new THREE.Scene();
const spark = new SparkRenderer({ renderer });
scene.add(spark);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.01, 1000);
camera.position.set(0, 5, 8);
camera.lookAt(0, 0, 0);
scene.add(camera);
window.addEventListener("resize", () => {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
});
const time = dyno.dynoFloat(0.0);
async function loadAssets() {
const env = await loadGLB(skyFile, true);
scene.add(env);
const meshes = [];
const period = dyno.dynoFloat(splatFiles.length);
const positions = [
new THREE.Vector3(-5, -2.2, -3),
new THREE.Vector3(5, -2.5, 0),
new THREE.Vector3(0, 1.5, 2)
];
for (let i = 0; i < splatFiles.length; i++) {
const url = await getAssetFileURL(splatFiles[i]);
const m = new SplatMesh({ url });
await m.initialized;
m.position.copy(positions[i]);
m.rotateX(Math.PI);
scene.add(m);
meshes.push(m);
}
const centers = meshes.map(m => {
const box = new THREE.Box3();
m.packedSplats.forEachSplat((_, c) => {
box.expandByPoint(c);
});
const localCenter = box.getCenter(new THREE.Vector3());
localCenter.y = -localCenter.y;
return localCenter.add(m.position);
});
const centerGLSL = `
vec3 getCenterOfMass(int idx) {
if (idx == 0) return vec3(${centers[0].x}, ${centers[0].y}, ${centers[0].z});
if (idx == 1) return vec3(${centers[1].x}, ${centers[1].y}, ${centers[1].z});
if (idx == 2) return vec3(${centers[2].x}, ${centers[2].y}, ${centers[2].z});
return vec3(0.0);
}
`;
meshes.forEach((m, i) => {
const { inTransition, isFadeIn, normT } = getTransitionState(time, dyno.dynoFloat(i), dyno.dynoFloat((i+1)%splatFiles.length), period);
m.worldModifier = getTransitionModifier(inTransition, isFadeIn, normT, dyno.dynoInt(i), time, centerGLSL, dyno.dynoBool(PARAMETERS.fixedMinScale), dyno.dynoFloat(PARAMETERS.waves));
m.updateGenerator();
});
return { splatMeshes: meshes, centerGLSL };
}
const { splatMeshes, centerGLSL } = await loadAssets();
const gui = new GUI();
gui.add(PARAMETERS, "speedMultiplier", 0.25, 4.0, 0.01);
gui.add(PARAMETERS, "objectRotation");
gui.add(PARAMETERS, "pause");
gui.add(PARAMETERS, "cameraRotation");
gui.add(PARAMETERS, "fixedMinScale").onChange(() => {
splatMeshes.forEach((m, i) => {
const { inTransition, isFadeIn, normT } = getTransitionState(time, dyno.dynoFloat(i), dyno.dynoFloat((i+1)%splatFiles.length), dyno.dynoFloat(splatFiles.length));
m.worldModifier = getTransitionModifier(inTransition, isFadeIn, normT, dyno.dynoInt(i), time, centerGLSL, dyno.dynoBool(PARAMETERS.fixedMinScale), dyno.dynoFloat(PARAMETERS.waves));
m.updateGenerator();
});
});
gui.add(PARAMETERS, "waves", 0, 1, 0.01).onChange(() => {
splatMeshes.forEach((m, i) => {
const { inTransition, isFadeIn, normT } = getTransitionState(time, dyno.dynoFloat(i), dyno.dynoFloat((i+1)%splatFiles.length), dyno.dynoFloat(splatFiles.length));
m.worldModifier = getTransitionModifier(inTransition, isFadeIn, normT, dyno.dynoInt(i), time, centerGLSL, dyno.dynoBool(PARAMETERS.fixedMinScale), dyno.dynoFloat(PARAMETERS.waves));
m.updateGenerator();
});
});
let last = 0;
renderer.setAnimationLoop(rt => {
const t = rt * 0.0005;
const dt = t - last;
last = t;
updateCamera(splatMeshes);
renderer.render(scene, camera);
if (!PARAMETERS.pause) {
time.value += dt * PARAMETERS.speedMultiplier;
if (PARAMETERS.objectRotation) splatMeshes.forEach(m => m.rotation.y += dt * PARAMETERS.speedMultiplier * 2);
}
});
function updateCamera(meshes) {
const cTime = 1 + PAUSE_SECONDS;
const tot = meshes.length * cTime;
const w = (time.value + PAUSE_SECONDS) % tot;
const cur = Math.floor(w / cTime);
const nxt = (cur + 1) % meshes.length;
const tr = w / cTime - cur;
const s = tr * tr * (3 - 2 * tr);
const tgt = new THREE.Vector3().lerpVectors(meshes[cur].position, meshes[nxt].position, s**5);
const radius = 4 + (Math.abs(s-0.5)**2)*20;
let x, z;
if (PARAMETERS.cameraRotation) {
const angle = -time.value * 0.5; // Velocidad de rotación
x = Math.cos(angle) * radius;
z = Math.sin(angle) * radius;
} else {
x = 0;
z = -radius;
}
const frm = tgt.clone().add(new THREE.Vector3(x, 2, z));
camera.position.copy(frm);
camera.lookAt(tgt);
}
</script>
</body>
</html>
+543
View File
@@ -0,0 +1,543 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Splat Painter</title>
<style>
html, body {
margin: 0;
height: 100%;
width: 100%;
background-color: black;
}
#canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
outline: none; /* Remove default focus outline */
touch-action: none;
}
#mode-overlay {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px 20px;
border-radius: 8px;
font-family: sans-serif;
font-size: 16px;
font-weight: bold;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
#mode-overlay.show {
opacity: 1;
}
</style>
</head>
<body>
<canvas id="canvas" tabindex="0"></canvas>
<div id="mode-overlay"></div>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import {
dyno,
SparkRenderer,
SplatMesh,
SplatTransformer,
SparkControls,
SpzWriter,
unpackSplat,
PackedSplats,
} from "@sparkjsdev/spark";
import * as THREE from "three";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
import { GUI } from "lil-gui";
const PARAMETERS = {
controlsEnabled: true,
eraseEnabled: dyno.dynoBool(false),
brushEnabled: dyno.dynoBool(false),
brushDepth: dyno.dynoFloat(10.0),
brushRadius: dyno.dynoFloat(0.05),
brushOrigin: dyno.dynoVec3(new THREE.Vector3(0.0, 0.0, 0.0)),
brushDirection: dyno.dynoVec3(new THREE.Vector3(0.0, 0.0, 0.0)),
brushColorHex: "#ff00ff",
brushColor: dyno.dynoVec3(new THREE.Vector3(1.0, 0.0, 1.0)),
};
const MIN_BRUSH_RADIUS = 0.01;
const MAX_BRUSH_RADIUS = 0.25;
const MIN_BRUSH_DEPTH = 0.1;
const MAX_BRUSH_DEPTH = 100.0;
const assetID = "greyscale-bedroom.spz";
let currentSplatMesh = null;
let currentFileName = "painted-splat";
function brushDyno(
brushEnabled,
eraseEnabled,
brushRadius,
brushDepth,
brushOrigin,
brushDirection,
brushColor,
) {
const flatColor = dyno.dynoVec3(new THREE.Vector3(1.0, 1.0, 1.0));
const luminanceThreshold = dyno.dynoFloat(0.1);
return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => {
if (!gsplat) {
throw new Error("No gsplat input");
}
let { center, rgb, opacity } = dyno.splitGsplat(gsplat).outputs;
const projectionAmplitude = dyno.dot(brushDirection, dyno.sub(center, brushOrigin));
const projectedCenter = dyno.add(brushOrigin, dyno.mul(brushDirection, projectionAmplitude));
const distance = dyno.length(dyno.sub(projectedCenter, center)); // distance from projected center to actual center
const isInside = dyno.and(dyno.lessThan(distance, brushRadius),
dyno.and(dyno.greaterThan(projectionAmplitude, dyno.dynoFloat(0.0)),
dyno.lessThan(projectionAmplitude, brushDepth)));
const luminanceOld = dyno.div(dyno.dot(rgb, flatColor), dyno.dynoFloat(3.0));
const luminanceNew = dyno.div(dyno.dot(brushColor, flatColor), dyno.dynoFloat(3.0));
const weightedRgb = dyno.mul(brushColor, dyno.div(luminanceOld, luminanceNew));
const isLuminanceAboveThreshold = dyno.greaterThan(luminanceOld, luminanceThreshold);
const newRgb = dyno.select(dyno.and(dyno.and(brushEnabled, isInside), isLuminanceAboveThreshold), weightedRgb, rgb);
const newOpacity = dyno.select(eraseEnabled, dyno.select(isInside, dyno.dynoFloat(0.0), opacity), opacity);
gsplat = dyno.combineGsplat({ gsplat, rgb: newRgb, opacity: newOpacity });
return { gsplat };
});
}
function paintableSplatMesh(
url,
brushEnabled,
eraseEnabled,
brushRadius,
brushDepth,
brushOrigin,
brushDirection,
brushColor,
) {
const splatMesh = new SplatMesh({
url: url,
onFrame: ({ mesh, time }) => {
mesh.needsUpdate = true;
}
});
splatMesh.worldModifier = brushDyno(
brushEnabled,
eraseEnabled,
brushRadius,
brushDepth,
brushOrigin,
brushDirection,
brushColor,
);
splatMesh.updateGenerator();
return splatMesh;
}
const canvas = document.getElementById("canvas");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false });
renderer.setClearColor(new THREE.Color(0xaaffff), 1);
const scene = new THREE.Scene();
const spark = new SparkRenderer({
renderer,
});
scene.add(spark);
const controls = new SparkControls({
canvas: renderer.domElement,
});
const camera = new THREE.PerspectiveCamera(
50,
window.innerWidth / window.innerHeight,
0.01,
1000,
);
camera.position.set(0, 0, 0);
camera.lookAt(0, 0, -1);
scene.add(camera);
function handleResize() {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
handleResize();
window.addEventListener("resize", handleResize);
async function loadSplatFromFile(url) {
if (currentSplatMesh) {
scene.remove(currentSplatMesh);
}
// Extract filename for export
currentFileName = url.split("/").pop().split("?")[0].split(".")[0] || "painted-splat";
currentSplatMesh = await paintableSplatMesh(
url,
PARAMETERS.brushEnabled,
PARAMETERS.eraseEnabled,
PARAMETERS.brushRadius,
PARAMETERS.brushDepth,
PARAMETERS.brushOrigin,
PARAMETERS.brushDirection,
PARAMETERS.brushColor
);
currentSplatMesh.quaternion.set(1, 0, 0, 0);
scene.add(currentSplatMesh);
}
await loadSplatFromFile(await getAssetFileURL(assetID));
const raycaster = new THREE.Raycaster();
let isDragging = false;
// Mode overlay functionality
const modeOverlay = document.getElementById('mode-overlay');
let overlayTimeout;
function showModeOverlay(text) {
modeOverlay.textContent = text;
modeOverlay.classList.add('show');
// Clear any existing timeout
if (overlayTimeout) {
clearTimeout(overlayTimeout);
}
// Hide overlay after 2 seconds
overlayTimeout = setTimeout(() => {
modeOverlay.classList.remove('show');
}, 2000);
}
renderer.domElement.addEventListener('pointermove', (event) => {
const clickCoords = new THREE.Vector2(
(event.clientX / renderer.domElement.width) * 2 - 1,
-(event.clientY / renderer.domElement.height) * 2 + 1,
);
raycaster.setFromCamera(clickCoords, camera);
const direction = raycaster.ray.direction.normalize();
PARAMETERS.brushDirection.value.x = direction.x;
PARAMETERS.brushDirection.value.y = direction.y;
PARAMETERS.brushDirection.value.z = direction.z;
PARAMETERS.brushOrigin.value.x = raycaster.ray.origin.x;
PARAMETERS.brushOrigin.value.y = raycaster.ray.origin.y;
PARAMETERS.brushOrigin.value.z = raycaster.ray.origin.z;
// Apply painting effect while dragging
if (isDragging && currentSplatMesh) {
const noSplatRgba = !currentSplatMesh.splatRgba;
currentSplatMesh.splatRgba = spark.getRgba(
{ generator: currentSplatMesh , rgba: currentSplatMesh.splatRgba}
);
if (noSplatRgba) {
currentSplatMesh.updateGenerator();
} else {
currentSplatMesh.updateVersion();
}
}
});
renderer.domElement.addEventListener('pointerdown', (event) => {
isDragging = true;
if (currentSplatMesh) {
const noSplatRgba = !currentSplatMesh.splatRgba;
currentSplatMesh.splatRgba = spark.getRgba(
{ generator: currentSplatMesh , rgba: currentSplatMesh.splatRgba}
);
if (noSplatRgba) {
currentSplatMesh.updateGenerator();
} else {
currentSplatMesh.updateVersion();
}
}
});
renderer.domElement.addEventListener('pointerup', (event) => {
isDragging = false;
});
renderer.domElement.addEventListener('pointerleave', (event) => {
isDragging = false;
});
const gui = new GUI();
// Instructions section
const instructions = {
brush: "Brush Mode",
erase: "Erase Mode",
none: "View Mode",
increase: "Increase Brush Size",
decrease: "Decrease Brush Size",
increaseDepth: "Increase Brush Depth",
decreaseDepth: "Decrease Brush Depth"
};
const instructionsFolder = gui.addFolder("Instructions");
instructionsFolder.add(instructions, "brush").name("1:").disable();
instructionsFolder.add(instructions, "erase").name("2:").disable();
instructionsFolder.add(instructions, "none").name("Esc:").disable();
instructionsFolder.add(instructions, "increase").name("=:").disable();
instructionsFolder.add(instructions, "decrease").name("-:").disable();
instructionsFolder.add(instructions, "increaseDepth").name("]:").disable();
instructionsFolder.add(instructions, "decreaseDepth").name("[:").disable();
instructionsFolder.open();
const brushRadiusController = gui.add(PARAMETERS.brushRadius, "value", MIN_BRUSH_RADIUS, MAX_BRUSH_RADIUS, 0.01).name("Brush Radius");
const brushDepthController = gui.add(PARAMETERS.brushDepth, "value", MIN_BRUSH_DEPTH, MAX_BRUSH_DEPTH, 0.1).name("Brush Depth");
gui.addColor(PARAMETERS, "brushColorHex").name("Brush Color").onChange((value) => {
PARAMETERS.brushColor.value = new THREE.Color(value).convertLinearToSRGB();
console.log(PARAMETERS.brushColor.value);
});
// I/O functionality (load and export)
const ioOptions = {
filename: currentFileName,
maxSh: 0, // Painted splats don't preserve SH data
fractionalBits: 12,
loadFile: () => {
// Create file input element
const input = document.createElement('input');
input.type = 'file';
input.accept = '.spz,.ply';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
// Create a blob URL from the file
const url = URL.createObjectURL(file);
try {
await loadSplatFromFile(url);
console.log("Loaded file:", file.name);
// Update filename for export
ioOptions.filename = file.name.split(".")[0] || "painted-splat";
} catch (error) {
console.error("Error loading file:", error);
alert("Failed to load file. Make sure it's a valid SPZ or PLY file.");
} finally {
// Clean up the object URL
URL.revokeObjectURL(url);
}
};
input.click();
},
saveToSpz: async () => {
if (!currentSplatMesh) {
console.error("Export failed - currentSplatMesh:", !!currentSplatMesh);
alert("No splat mesh loaded for export.");
return;
}
try {
console.log("Starting SPZ export with painted changes...");
if (!currentSplatMesh.splatRgba) {
currentSplatMesh.splatRgba = spark.getRgba({
generator: currentSplatMesh,
rgba: currentSplatMesh.splatRgba
});
currentSplatMesh.updateGenerator();
}
const rgbaBytes = await spark.readRgba({
generator: currentSplatMesh,
rgba: currentSplatMesh.splatRgba
});
const ogSplats = currentSplatMesh.packedSplats;
const totalSplats = ogSplats.numSplats;
console.log("Total splats:", totalSplats);
let nonZeroCount = 0;
for (let i = 0; i < totalSplats; i++) {
const opacity = rgbaBytes[i * 4 + 3] / 255;
if (opacity > 0) {
nonZeroCount++;
}
}
// Create new PackedSplats with baked changes
const newPackedSplats = new PackedSplats({
maxSplats: nonZeroCount,
splatEncoding: ogSplats.splatEncoding,
});
// Build splat array from baked RGBA
let processedCount = 0;
for (let i = 0; i < totalSplats; i++) {
const rgbaOffset = i * 4;
const opacity = rgbaBytes[rgbaOffset + 3] / 255;
// Skip erased splats (zero opacity)
if (opacity === 0) {
continue;
}
// Unpack geometry from original packed array
const unpacked = unpackSplat(
ogSplats.packedArray,
i,
ogSplats.splatEncoding
);
// Replace color/opacity with baked painted values
unpacked.color.r = rgbaBytes[rgbaOffset + 0] / 255;
unpacked.color.g = rgbaBytes[rgbaOffset + 1] / 255;
unpacked.color.b = rgbaBytes[rgbaOffset + 2] / 255;
unpacked.opacity = opacity;
// Push to new PackedSplats
newPackedSplats.pushSplat(
unpacked.center,
unpacked.scales,
unpacked.quaternion,
unpacked.opacity,
unpacked.color
);
processedCount++;
}
console.log(`Processed ${processedCount} splats`);
// Now export the PackedSplats to SPZ
console.log("Creating SPZ writer...");
const maxSh = ioOptions.maxSh;
const spzWriter = new SpzWriter({
numSplats: nonZeroCount,
shDegree: maxSh,
fractionalBits: ioOptions.fractionalBits,
flagAntiAlias: true,
});
console.log("Writing splats to SPZ...");
// Iterate through the new packed array
for (let i = 0; i < nonZeroCount; i++) {
const unpacked = unpackSplat(
newPackedSplats.packedArray,
i,
newPackedSplats.splatEncoding
);
spzWriter.setCenter(i, unpacked.center.x, unpacked.center.y, unpacked.center.z);
spzWriter.setScale(i, unpacked.scales.x, unpacked.scales.y, unpacked.scales.z);
spzWriter.setQuat(i, unpacked.quaternion.x, unpacked.quaternion.y, unpacked.quaternion.z, unpacked.quaternion.w);
spzWriter.setAlpha(i, unpacked.opacity);
spzWriter.setRgb(i, unpacked.color.r, unpacked.color.g, unpacked.color.b);
}
const spzBytes = await spzWriter.finalize();
if (spzWriter.clippedCount > 0) {
console.log(`Clipped ${spzWriter.clippedCount} splats. Consider decreasing fractional-bits from ${ioOptions.fractionalBits} to reduce clipping.`);
}
console.log("Creating download...");
const blob = new Blob([spzBytes], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = ioOptions.filename + "-painted.spz";
a.click();
URL.revokeObjectURL(url);
console.log("SPZ file with painted changes downloaded successfully:", ioOptions.filename + "-painted.spz");
} catch (error) {
console.error("Error exporting SPZ:", error);
console.error("Error stack:", error.stack);
}
},
};
const ioFolder = gui.addFolder("I/O");
ioFolder.add(ioOptions, "loadFile").name("Load Splats (SPZ/PLY)");
ioFolder.add(ioOptions, "saveToSpz").name("Save Splats (SPZ)");
ioFolder.open();
// Keyboard controls
window.addEventListener('keydown', (event) => {
if (event.key === '1') {
// Brush mode
PARAMETERS.brushEnabled.value = true;
PARAMETERS.eraseEnabled.value = false;
PARAMETERS.controlsEnabled = false;
controls.enabled = false;
showModeOverlay('Paint Mode');
} else if (event.key === '2') {
// Eraser mode
PARAMETERS.brushEnabled.value = false;
PARAMETERS.eraseEnabled.value = true;
PARAMETERS.controlsEnabled = false;
controls.enabled = false;
showModeOverlay('Erase Mode');
} else if (event.key === 'Escape') {
// View mode
PARAMETERS.brushEnabled.value = false;
PARAMETERS.eraseEnabled.value = false;
PARAMETERS.controlsEnabled = true;
controls.enabled = true;
showModeOverlay('View Mode');
} else if (event.key === '=' || event.key === '+') {
// Increase brush radius
const currentRadius = PARAMETERS.brushRadius.value;
const newRadius = Math.min(currentRadius + 0.01, MAX_BRUSH_RADIUS);
PARAMETERS.brushRadius.value = newRadius;
brushRadiusController.updateDisplay();
} else if (event.key === '-' || event.key === '_') {
// Decrease brush radius
const currentRadius = PARAMETERS.brushRadius.value;
const newRadius = Math.max(currentRadius - 0.01, MIN_BRUSH_RADIUS);
PARAMETERS.brushRadius.value = newRadius;
brushRadiusController.updateDisplay();
} else if (event.key === ']') {
// Increase brush depth
const currentDepth = PARAMETERS.brushDepth.value;
const newDepth = Math.min(currentDepth + 0.5, MAX_BRUSH_DEPTH);
PARAMETERS.brushDepth.value = newDepth;
brushDepthController.updateDisplay();
} else if (event.key === '[') {
// Decrease brush depth
const currentDepth = PARAMETERS.brushDepth.value;
const newDepth = Math.max(currentDepth - 0.5, MIN_BRUSH_DEPTH);
PARAMETERS.brushDepth.value = newDepth;
brushDepthController.updateDisplay();
}
});
console.log("Starting render loop");
// Animation loop
let lastTime = 0;
renderer.setAnimationLoop((rawTime) => {
rawTime *= 0.0005;
const deltaTime = rawTime - (lastTime ?? rawTime);
lastTime = rawTime;
if (PARAMETERS.controlsEnabled) {
controls.update(camera);
}
renderer.render(scene, camera);
});
</script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>spark | splat-portal</title>
<style>
html, body, #canvas { width: 100%; height: 100%; margin: 0; padding: 0; display: block; }
body { background: #000; overflow: hidden; }
.legend {
position: absolute;
top: 20px;
left: 20px;
color: white;
font-family: Arial, sans-serif;
font-size: 16px;
background: rgba(0, 0, 0, 0.7);
padding: 10px 15px;
border-radius: 5px;
pointer-events: none;
z-index: 100;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div class="legend">WASD + Mouse to move camera • Go through portal to switch between worlds</div>
<script type="module" src="./main.js"></script>
</body>
</html>
+405
View File
@@ -0,0 +1,405 @@
import { SparkControls, SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
import * as THREE from "three";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const canvas = document.getElementById("canvas");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
renderer.setClearColor(0x000000, 1);
// Two independent scenes (world A and world B)
const sceneA = new THREE.Scene();
const sceneB = new THREE.Scene();
const sparkA = new SparkRenderer({ renderer });
const sparkB = new SparkRenderer({ renderer });
sceneA.add(sparkA);
sceneB.add(sparkB);
// Main camera (not parented to scenes)
const camera = new THREE.PerspectiveCamera(
50,
canvas.clientWidth / canvas.clientHeight,
0.01,
2000,
);
camera.position.set(0, 1, 3);
camera.lookAt(0, 1, 0);
// Offscreen render targets for portal views
const rtAtoB = new THREE.WebGLRenderTarget(
canvas.clientWidth,
canvas.clientHeight,
{
depthBuffer: true,
},
);
rtAtoB.texture.minFilter = THREE.LinearFilter;
rtAtoB.texture.magFilter = THREE.LinearFilter;
rtAtoB.texture.generateMipmaps = false;
const rtBtoA = new THREE.WebGLRenderTarget(
canvas.clientWidth,
canvas.clientHeight,
{
depthBuffer: true,
},
);
rtBtoA.texture.minFilter = THREE.LinearFilter;
rtBtoA.texture.magFilter = THREE.LinearFilter;
rtBtoA.texture.generateMipmaps = false;
function resizeRenderTargets(width, height) {
const dpr =
typeof renderer.getPixelRatio === "function"
? renderer.getPixelRatio()
: window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(width * dpr));
const h = Math.max(1, Math.floor(height * dpr));
if (rtAtoB) {
rtAtoB.setSize(w, h);
}
if (rtBtoA) {
rtBtoA.setSize(w, h);
}
}
function handleResize() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
resizeRenderTargets(w, h);
}
window.addEventListener("resize", handleResize);
// Camera controls with mouse and WASD enabled
const controls = new SparkControls({ canvas: renderer.domElement });
controls.fpsMovement.enable = true;
controls.pointerControls.enable = true;
// Portal helpers
function makePortalMaterial() {
const material = new THREE.ShaderMaterial({
uniforms: {
tMap: { value: null },
portalPV: { value: new THREE.Matrix4() },
portalBridge: { value: new THREE.Matrix4() },
worldMatrix: { value: new THREE.Matrix4() },
circleRadius: { value: 0.6 },
time: { value: 0.0 },
waveStrength: { value: 0.001 },
waveSpeed: { value: 10.0 },
waveFrequency: { value: 50.0 },
edgeSoftness: { value: 0.2 },
},
vertexShader: `
varying vec3 vWorldPosition;
varying vec2 vLocalXY;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPos.xyz;
vLocalXY = position.xy;
gl_Position = projectionMatrix * viewMatrix * worldPos;
}
`,
fragmentShader: `
uniform sampler2D tMap;
uniform mat4 portalPV;
uniform mat4 portalBridge;
uniform float circleRadius;
uniform float time;
uniform float waveStrength;
uniform float waveSpeed;
uniform float waveFrequency;
uniform float edgeSoftness;
varying vec3 vWorldPosition;
varying vec2 vLocalXY;
void main() {
float r = length(vLocalXY);
if (r > circleRadius) discard;
// Generate radial waves
float wave = sin(r * waveFrequency - time * waveSpeed);
float distortion = wave * waveStrength;
// Deform UV radially based on wave
vec2 direction = normalize(vLocalXY);
vec2 offset = direction * distortion;
vec4 targetPos = portalBridge * vec4(vWorldPosition, 1.0);
vec4 clip = portalPV * targetPos;
vec3 ndc = clip.xyz / max(clip.w, 1e-6);
vec2 uv = ndc.xy * 0.5 + 0.5;
// Apply wave distortion to UV
uv += offset;
// Clamp UVs instead of discarding to avoid black edges
uv = clamp(uv, vec2(0.0), vec2(1.0));
vec4 color = texture2D(tMap, uv);
color+=offset.x*50.;
// Edge fadeout by reducing brightness instead of alpha
float edgeFade = 1.0 - smoothstep(circleRadius * (1.0 - edgeSoftness), circleRadius, r);
color.rgb *= .1+edgeFade;
gl_FragColor = color;
}
`,
side: THREE.DoubleSide,
transparent: false,
depthWrite: true,
depthTest: true,
});
return material;
}
function makePortal(radius) {
const geom = new THREE.CircleGeometry(radius, 64);
const mat = makePortalMaterial();
const mesh = new THREE.Mesh(geom, mat);
mesh.renderOrder = 1000;
return mesh;
}
function buildPVMatrix(cameraObj) {
const pv = new THREE.Matrix4();
pv.multiplyMatrices(cameraObj.projectionMatrix, cameraObj.matrixWorldInverse);
return pv;
}
function buildPortalBridgeMatrix(sourcePortal, targetPortal) {
// Bridge = targetPortal * Ry(PI) * inverse(sourcePortal)
const invSrc = new THREE.Matrix4().copy(sourcePortal.matrixWorld).invert();
const rotY180 = new THREE.Matrix4().makeRotationY(Math.PI);
const tmp = new THREE.Matrix4().multiplyMatrices(rotY180, invSrc);
const bridge = new THREE.Matrix4().multiplyMatrices(
targetPortal.matrixWorld,
tmp,
);
return bridge;
}
function computeLinkedCamera(
sourcePortal,
targetPortal,
fromCamera,
outCamera,
clampRadius = 0,
) {
// outCameraWorld = targetPortal * R_y(PI) * inverse(sourcePortal) * fromCameraWorld
const invSrc = new THREE.Matrix4().copy(sourcePortal.matrixWorld).invert();
const rotY180 = new THREE.Matrix4().makeRotationY(Math.PI);
const tmp = new THREE.Matrix4().multiplyMatrices(
invSrc,
fromCamera.matrixWorld,
);
const withRot = new THREE.Matrix4().multiplyMatrices(rotY180, tmp);
const dst = new THREE.Matrix4().multiplyMatrices(
targetPortal.matrixWorld,
withRot,
);
outCamera.matrixWorld.copy(dst);
outCamera.matrixWorld.decompose(
outCamera.position,
outCamera.quaternion,
outCamera.scale,
);
// Clamp camera position if radius is specified
if (clampRadius > 0) {
const targetPos = new THREE.Vector3();
targetPortal.getWorldPosition(targetPos);
const toCam = outCamera.position.clone().sub(targetPos);
const distance = toCam.length();
if (distance > clampRadius) {
toCam.normalize().multiplyScalar(clampRadius);
outCamera.position.copy(targetPos).add(toCam);
outCamera.updateMatrixWorld(true);
}
}
outCamera.projectionMatrix.copy(fromCamera.projectionMatrix);
outCamera.updateMatrixWorld(true);
}
function transformPoseThroughPortal(sourcePortal, targetPortal, object3D) {
const srcWorld = sourcePortal.matrixWorld;
const dstWorld = targetPortal.matrixWorld;
const invSrc = new THREE.Matrix4().copy(srcWorld).invert();
const localMat = new THREE.Matrix4().multiplyMatrices(
invSrc,
object3D.matrixWorld,
);
const rotY180 = new THREE.Matrix4().makeRotationY(Math.PI);
const dstMat = new THREE.Matrix4().multiplyMatrices(
dstWorld,
new THREE.Matrix4().multiplyMatrices(rotY180, localMat),
);
object3D.matrixWorld.copy(dstMat);
object3D.matrixWorld.decompose(
object3D.position,
object3D.quaternion,
object3D.scale,
);
}
async function run() {
// Load valley (world A)
const valleyURL = await getAssetFileURL("valley.spz");
const valley = new SplatMesh({ url: valleyURL });
await valley.initialized;
valley.rotateX(Math.PI);
sceneA.add(valley);
// Load sutro (world B)
const sutroURL = await getAssetFileURL("sutro.zip");
const sutro = new SplatMesh({ url: sutroURL });
await sutro.initialized;
sutro.rotateX(Math.PI); // Fix orientation
sutro.position.set(0, 0, 0);
sutro.scale.set(3.5, 3.5, 3.5);
sceneB.add(sutro);
// Portals in each world
const portalRadius = 0.6;
const portalA = makePortal(portalRadius);
portalA.position.set(0, 1, -2);
portalA.rotation.set(0, 0, 0);
sceneA.add(portalA);
const portalB = makePortal(portalRadius);
portalB.position.set(-1, 1, -5);
portalB.rotation.set(0, Math.PI, 0);
sceneB.add(portalB);
portalA.updateMatrixWorld(true);
portalB.updateMatrixWorld(true);
// Offscreen cameras
const camAtoB = new THREE.PerspectiveCamera();
const camBtoA = new THREE.PerspectiveCamera();
// Teleportation tracking
let activeWorld = "A";
let lastPortalSide = -1; // -1 = behind portal, 1 = in front
let teleportCooldown = 0;
const TELEPORT_COOLDOWN_MS = 500; // 500ms cooldown
const CROSSING_THRESHOLD = 0.3; // Distance threshold for crossing detection
function getDistanceFromPortal(portal, pointWorld) {
const local = pointWorld.clone();
portal.worldToLocal(local);
return local.z; // positive = in front, negative = behind
}
function withinRadius(portal, pointWorld, radius) {
const local = pointWorld.clone();
portal.worldToLocal(local);
const d = Math.hypot(local.x, local.y);
return d <= radius;
}
renderer.setAnimationLoop((timeMs) => {
// Decrease cooldown timer
if (teleportCooldown > 0) {
teleportCooldown -= 16; // assuming ~60fps
}
// Controls and camera updates
controls.update(camera);
camera.updateMatrixWorld(true);
// Update time for wave animation
const time = timeMs * 0.001; // Convert to seconds
portalA.material.uniforms.time.value = time;
portalB.material.uniforms.time.value = time;
// Prepare portal cameras
portalA.updateMatrixWorld(true);
portalB.updateMatrixWorld(true);
computeLinkedCamera(portalA, portalB, camera, camAtoB, 3);
computeLinkedCamera(portalB, portalA, camera, camBtoA, 3);
// Render other worlds into targets (hide portals to avoid recursion/artifacts)
const prevAVisible = portalA.visible;
const prevBVisible = portalB.visible;
portalA.visible = false;
portalB.visible = false;
renderer.setRenderTarget(rtAtoB);
renderer.clear(true, true, true);
renderer.render(sceneB, camAtoB);
renderer.setRenderTarget(rtBtoA);
renderer.clear(true, true, true);
renderer.render(sceneA, camBtoA);
renderer.setRenderTarget(null);
portalA.visible = prevAVisible;
portalB.visible = prevBVisible;
// Update portal materials with PV matrices composed with portal bridges
const pvA = buildPVMatrix(camAtoB);
const pvB = buildPVMatrix(camBtoA);
const bridgeAtoB = buildPortalBridgeMatrix(portalA, portalB);
const bridgeBtoA = buildPortalBridgeMatrix(portalB, portalA);
const matA = portalA.material;
const matB = portalB.material;
matA.uniforms.tMap.value = rtAtoB.texture;
matA.uniforms.portalPV.value.copy(pvA);
matA.uniforms.portalBridge.value.copy(bridgeAtoB);
matB.uniforms.tMap.value = rtBtoA.texture;
matB.uniforms.portalPV.value.copy(pvB);
matB.uniforms.portalBridge.value.copy(bridgeBtoA);
// Teleport logic when crossing the active world's portal
if (teleportCooldown <= 0) {
const camPos = camera.position.clone();
const activePortal = activeWorld === "A" ? portalA : portalB;
const distance = getDistanceFromPortal(activePortal, camPos);
const currentSide = Math.sign(distance);
const absDistance = Math.abs(distance);
// Check if we're crossing the portal (from either side)
const isCrossing =
absDistance < CROSSING_THRESHOLD &&
withinRadius(activePortal, camPos, portalRadius * 1.2);
// Detect side change: going from front to back or back to front
const sideChanged =
lastPortalSide !== 0 && currentSide !== lastPortalSide;
if (isCrossing && (sideChanged || absDistance < 0.1)) {
const src = activeWorld === "A" ? portalA : portalB;
const dst = activeWorld === "A" ? portalB : portalA;
transformPoseThroughPortal(src, dst, camera);
// Calculate forward direction relative to the destination portal
const forward = new THREE.Vector3(0, 0, -1)
.applyQuaternion(camera.quaternion)
.multiplyScalar(0.2);
camera.position.add(forward);
camera.updateMatrixWorld(true);
activeWorld = activeWorld === "A" ? "B" : "A";
teleportCooldown = TELEPORT_COOLDOWN_MS;
lastPortalSide = currentSide; // Update side tracking
} else {
lastPortalSide = currentSide;
}
}
// Render active world to screen
if (activeWorld === "A") {
renderer.render(sceneA, camera);
} else {
renderer.render(sceneB, camera);
}
});
}
run();
@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Splat Reveal Effects</title>
<style>
body {
margin: 0;
}
canvas {
touch-action: none;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SparkControls, SplatMesh, dyno } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
import GUI from "lil-gui";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
// Initialize camera with elevated perspective
camera.position.set(0, 2, 2.5);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// Animation timing variables
const animateT = dyno.dynoFloat(0);
let baseTime = 0;
let splatLoaded = false;
// Camera orbit parameters
let cameraAngle = 0;
const cameraRadius = 3;
const cameraHeight = 2;
const rotationSpeed = 0.2;
// Available visual effects configuration
const effectParams = {
effect: "Magic"
};
let splatMesh = null;
/**
* Loads and configures splat mesh based on selected effect
* @param {string} effect - The effect type (Magic, Spread, Unroll, Twister, or Rain)
*/
async function loadSplatForEffect(effect) {
// Clean up existing splat mesh
if (splatMesh) {
scene.remove(splatMesh);
splatMesh = null;
}
// Configure splat file and positioning per effect
let splatFileName, position;
if (effect === "Magic") {
splatFileName = "primerib-tamos.spz";
position = [0, 0, 0];
} else if (effect === "Spread") {
splatFileName = "valley.spz";
position = [0, 1, 1];
} else if (effect === "Unroll") {
splatFileName = "burger-from-amboy.spz";
position = [0, 0, 0];
} else if (effect === "Twister" || effect === "Rain") {
splatFileName = "sutro.zip";
position = [0, -1, 1];
}
// Load and initialize new splat mesh
const splatURL = await getAssetFileURL(splatFileName);
splatMesh = new SplatMesh({ url: splatURL });
splatMesh.quaternion.set(1, 0, 0, 0);
splatMesh.position.set(position[0], position[1], position[2]);
// Apply special scaling for Unroll effect
if (effect === "Unroll") {
splatMesh.scale.set(1.5, 1.5, 1.5);
} else if (effect === "Twister" || effect === "Rain") {
splatMesh.scale.set(0.8, 0.8, 0.8);
}
scene.add(splatMesh);
// Wait for asset loading and reset animation timing
splatLoaded = false;
await splatMesh.loaded;
splatLoaded = true;
baseTime = 0;
// Apply visual effects to the loaded splat
setupSplatModifier();
}
// Initialize user interface
const gui = new GUI();
const effectFolder = gui.addFolder('Effects');
// Effect selector dropdown
effectFolder.add(effectParams, 'effect', ['Magic', 'Spread', 'Unroll', 'Twister', 'Rain'])
.name('Effect Type')
.onChange(async () => {
await loadSplatForEffect(effectParams.effect);
});
// Animation controls
const guiControls = {
resetTime: () => {
baseTime = 0;
animateT.value = 0;
}
};
effectFolder.add(guiControls, 'resetTime').name('Reset Time');
effectFolder.open();
/**
* Configures visual effects shader for the current splat mesh
*/
function setupSplatModifier() {
splatMesh.objectModifier = dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => {
const d = new dyno.Dyno({
inTypes: { gsplat: dyno.Gsplat, t: "float", effectType: "int" },
outTypes: { gsplat: dyno.Gsplat },
// GLSL utility functions for effects
globals: () => [
dyno.unindent(`
// Pseudo-random hash function
vec3 hash(vec3 p) {
p = fract(p * 0.3183099 + 0.1);
p *= 17.0;
return fract(vec3(p.x * p.y * p.z, p.x + p.y * p.z, p.x * p.y + p.z));
}
// 3D Perlin-style noise function
vec3 noise(vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
vec3 n000 = hash(i + vec3(0,0,0));
vec3 n100 = hash(i + vec3(1,0,0));
vec3 n010 = hash(i + vec3(0,1,0));
vec3 n110 = hash(i + vec3(1,1,0));
vec3 n001 = hash(i + vec3(0,0,1));
vec3 n101 = hash(i + vec3(1,0,1));
vec3 n011 = hash(i + vec3(0,1,1));
vec3 n111 = hash(i + vec3(1,1,1));
vec3 x0 = mix(n000, n100, f.x);
vec3 x1 = mix(n010, n110, f.x);
vec3 x2 = mix(n001, n101, f.x);
vec3 x3 = mix(n011, n111, f.x);
vec3 y0 = mix(x0, x1, f.y);
vec3 y1 = mix(x2, x3, f.y);
return mix(y0, y1, f.z);
}
// 2D rotation matrix
mat2 rot(float a) {
float s=sin(a),c=cos(a);
return mat2(c,-s,s,c);
}
// Twister weather effect
vec4 twister(vec3 pos, vec3 scale, float t) {
vec3 h = hash(pos);
float s = smoothstep(0., 8., t*t*.1 - length(pos.xz)*2.+2.);
if (length(scale) < .05) pos.y = mix(-10., pos.y, pow(s, 2.*h.x));
pos.xz = mix(pos.xz*.5, pos.xz, pow(s, 2.*h.x));
float rotationTime = t * (1.0 - s) * 0.2;
pos.xz *= rot(rotationTime + pos.y*20.*(1.-s)*exp(-1.*length(pos.xz)));
return vec4(pos, s*s*s*s);
}
// Rain weather effect
vec4 rain(vec3 pos, vec3 scale, float t) {
vec3 h = hash(pos);
float s = pow(smoothstep(0., 5., t*t*.1 - length(pos.xz)*2. + 1.), .5 + h.x);
float y = pos.y;
pos.y = min(-10. + s*15., pos.y);
pos.xz = mix(pos.xz*.3, pos.xz, s);
pos.xz *= rot(t*.3);
return vec4(pos, smoothstep(-10., y, pos.y));
}
`)
],
// Main effect shader logic
statements: ({ inputs, outputs }) => dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
float t = ${inputs.t};
float s = smoothstep(0.,10.,t-4.5)*10.;
vec3 scales = ${inputs.gsplat}.scales;
vec3 localPos = ${inputs.gsplat}.center;
float l = length(localPos.xz);
if (${inputs.effectType} == 1) {
// Magic Effect: Complex twister with noise and radial reveal
float border = abs(s-l-.5);
localPos *= 1.-.2*exp(-20.*border);
vec3 finalScales = mix(scales,vec3(0.002),smoothstep(s-.5,s,l+.5));
${outputs.gsplat}.center = localPos + .1*noise(localPos.xyz*2.+t*.5)*smoothstep(s-.5,s,l+.5);
${outputs.gsplat}.scales = finalScales;
float at = atan(localPos.x,localPos.z)/3.1416;
${outputs.gsplat}.rgba *= step(at,t-3.1416);
${outputs.gsplat}.rgba += exp(-20.*border) + exp(-50.*abs(t-at-3.1416))*.5;
} else if (${inputs.effectType} == 2) {
// Spread Effect: Gentle radial emergence with scaling
float tt = t*t*.4+.5;
localPos.xz *= min(1.,.3+max(0.,tt*.05));
${outputs.gsplat}.center = localPos;
${outputs.gsplat}.scales = max(mix(vec3(0.0),scales,min(tt-7.-l*2.5,1.)),mix(vec3(0.0),scales*.2,min(tt-1.-l*2.,1.)));
${outputs.gsplat}.rgba = mix(vec4(.3),${inputs.gsplat}.rgba,clamp(tt-l*2.5-3.,0.,1.));
} else if (${inputs.effectType} == 3) {
// Unroll Effect: Rotating helix with vertical reveal
localPos.xz *= rot((localPos.y*50.-20.)*exp(-t));
${outputs.gsplat}.center = localPos * (1.-exp(-t)*2.);
${outputs.gsplat}.scales = mix(vec3(0.002),scales,smoothstep(.3,.7,t+localPos.y-2.));
${outputs.gsplat}.rgba = ${inputs.gsplat}.rgba*step(0.,t*.5+localPos.y-.5);
} else if (${inputs.effectType} == 4) {
// Twister Effect: swirling weather reveal
vec4 effectResult = twister(localPos, scales, t);
${outputs.gsplat}.center = effectResult.xyz;
${outputs.gsplat}.scales = mix(vec3(.002), scales, pow(effectResult.w, 12.));
float s = effectResult.w;
// Also apply a spin (self-rotation) so each splat rotates about its own center.
float spin = -t * 0.3 * (1.0 - s);
vec4 spinQ = vec4(0.0, sin(spin*0.5), 0.0, cos(spin*0.5));
${outputs.gsplat}.quaternion = quatQuat(spinQ, ${inputs.gsplat}.quaternion);
} else if (${inputs.effectType} == 5) {
// Rain Effect: falling streaks
vec4 effectResult = rain(localPos, scales, t);
${outputs.gsplat}.center = effectResult.xyz;
${outputs.gsplat}.scales = mix(vec3(.005), scales, pow(effectResult.w, 30.));
// Also apply a spin (self-rotation) so each splat rotates about its own center.
float spin = -t*.3;
vec4 spinQ = vec4(0.0, sin(spin*0.5), 0.0, cos(spin*0.5));
${outputs.gsplat}.quaternion = quatQuat(spinQ, ${inputs.gsplat}.quaternion);
}
`),
});
// Map effect names to shader integer constants
const effectType = effectParams.effect === "Magic" ? 1 :
effectParams.effect === "Spread" ? 2 :
effectParams.effect === "Unroll" ? 3 :
effectParams.effect === "Twister" ? 4 : 5;
gsplat = d.apply({
gsplat,
t: animateT,
effectType: dyno.dynoInt(effectType)
}).gsplat;
return { gsplat };
}
);
// Apply shader modifications to splat mesh
splatMesh.updateGenerator();
}
// Initialize with default effect
await loadSplatForEffect(effectParams.effect);
// Initialize camera controls and start render loop
const controls = new SparkControls({ canvas: renderer.domElement });
renderer.setAnimationLoop(function animate(time) {
// Update animation timing
if (splatLoaded) {
baseTime += 1/60;
animateT.value = baseTime;
} else {
animateT.value = 0;
}
// Orbit camera only for non-weather effects
cameraAngle += rotationSpeed * (1/60);
if (effectParams.effect == "Twister" || effectParams.effect == "Rain") cameraAngle = 0;
camera.position.x = Math.cos(cameraAngle) * cameraRadius;
camera.position.z = Math.sin(cameraAngle) * cameraRadius;
camera.position.y = cameraHeight;
// Adjust camera target based on current effect
if (effectParams.effect === "Spread") {
camera.lookAt(0, 1, 0);
} else {
camera.lookAt(0, 0, 0);
}
// Update splat rendering if available
if (splatMesh) {
splatMesh.updateVersion();
}
controls.update(camera);
renderer.render(scene, camera);
});
</script>
</body>
</html>
@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • GLSL Shaders</title>
<style>
body { margin: 0; }
canvas { touch-action: none; }
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SparkControls, SplatMesh, dyno } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
import GUI from "lil-gui";
// Scene setup
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// Cat model setup
const splatURL = await getAssetFileURL("cat.spz");
const cat = new SplatMesh({ url: splatURL });
cat.quaternion.set(1, 0, 0, 0);
cat.position.set(0, 0, -1.5);
cat.scale.set(.5, .5, .5);
scene.add(cat);
const animateT = dyno.dynoFloat(0);
// Effect parameters and GUI
const effectParams = {
effect: "Disintegrate",
intensity: 0.8
};
const gui = new GUI({ title: "Fractal Effects" });
gui.add(effectParams, "effect", ["Electronic", "Deep Meditation", "Waves", "Disintegrate", "Flare"])
.name("Effect Type")
.onChange(() => cat.updateGenerator());
gui.add(effectParams, "intensity", 0, 1, 0.01)
.name("Intensity")
.onChange(() => cat.updateGenerator());
// Shader modifier setup
cat.objectModifier = dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => {
const d = new dyno.Dyno({
inTypes: {
gsplat: dyno.Gsplat,
t: "float",
effectType: "int",
intensity: "float"
},
outTypes: { gsplat: dyno.Gsplat },
globals: () => [
dyno.unindent(`
vec3 hash(vec3 p) {
return fract(sin(p*123.456)*123.456);
}
mat2 rot(float a) {
float s = sin(a), c = cos(a);
return mat2(c, -s, s, c);
}
vec3 headMovement(vec3 pos, float t) {
pos.xy *= rot(smoothstep(-1., -2., pos.y) * .2 * sin(t*2.));
return pos;
}
vec3 breathAnimation(vec3 pos, float t) {
float b = sin(t*1.5);
pos.yz *= rot(smoothstep(-1., -3., pos.y) * .15 * -b);
pos.z += .3;
pos.y += 1.2;
pos *= 1. + exp(-3. * length(pos)) * b;
pos.z -= .3;
pos.y -= 1.2;
return pos;
}
vec4 fractal1(vec3 pos, float t, float intensity) {
float m = 100.;
vec3 p = pos * .1;
p.y += .5;
for (int i = 0; i < 8; i++) {
p = abs(p) / clamp(abs(p.x * p.y), 0.3, 3.) - 1.;
p.xy *= rot(radians(90.));
if (i > 1) m = min(m, length(p.xy) + step(.3, fract(p.z * .5 + t * .5 + float(i) * .2)));
}
m = step(m, 0.5) * 1.3 * intensity;
return vec4(-pos.y * .3, 0.5, 0.7, .3) * intensity + m;
}
vec4 fractal2(vec3 center, vec3 scales, vec4 rgba, float t, float intensity) {
vec3 pos = center;
float splatSize = length(scales);
float pattern = exp(-50. * splatSize);
vec3 p = pos * .65;
pos.y += 2.;
float c = 0.;
float l, l2 = length(p);
float m = 100.;
for (int i = 0; i < 10; i++) {
p.xyz = abs(p.xyz) / dot(p.xyz, p.xyz) - .8;
l = length(p.xyz);
c += exp(-1. * abs(l - l2) * (1. + sin(t * 1.5 + pos.y)));
l2 = length(p.xyz);
m = min(m, length(p.xyz));
}
c = smoothstep(0.3, 0.5, m + sin(t * 1.5 + pos.y * .5)) + c * .1;
return vec4(vec3(length(rgba.rgb)) * vec3(c, c*c, c*c*c) * intensity,
rgba.a * exp(-20. * splatSize) * m * intensity);
}
vec4 sin3D(vec3 p, float t) {
float m = exp(-2. * length(sin(p * 5. + t * 3.))) * 5.;
return vec4(m) + .3;
}
vec4 disintegrate(vec3 pos, float t, float intensity) {
vec3 p = pos + (hash(pos) * 2. - 1.) * intensity;
float tt = smoothstep(-1., 0.5, -sin(t + -pos.y * .5));
p.xz *= rot(tt * 2. + p.y * 2. * tt);
return vec4(mix(p, pos, tt), tt);
}
vec4 flare(vec3 pos, float t) {
vec3 p = vec3(0., -1.5, 0.);
float tt = smoothstep(-1., .5, sin(t + hash(pos).x));
tt = tt * tt;
p.x += sin(t * 2.) * tt;
p.z += sin(t * 2.) * tt;
p.y += sin(t) * tt;
return vec4(mix(pos, p, tt), tt);
}
`)
],
statements: ({ inputs, outputs }) => dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
vec3 localPos = ${inputs.gsplat}.center;
vec3 splatScales = ${inputs.gsplat}.scales;
vec4 splatColor = ${inputs.gsplat}.rgba;
if (${inputs.effectType} == 1) {
${outputs.gsplat}.center = headMovement(localPos, ${inputs.t});
vec4 effect1 = fractal1(localPos, ${inputs.t}, ${inputs.intensity});
${outputs.gsplat}.rgba.rgba = mix(splatColor, splatColor*effect1, ${inputs.intensity});
}
else if (${inputs.effectType} == 2) {
vec4 effectColor = fractal2(localPos, splatScales, splatColor, ${inputs.t}, ${inputs.intensity});
${outputs.gsplat}.rgba.rgba = mix(splatColor, effectColor, ${inputs.intensity});
${outputs.gsplat}.center = breathAnimation(localPos, ${inputs.t});
}
else if (${inputs.effectType} == 3) {
vec4 effect = sin3D(localPos, ${inputs.t});
${outputs.gsplat}.rgba.rgba = mix(splatColor, splatColor*effect, ${inputs.intensity});
vec3 pos = localPos;
pos.y += 1.;
pos *= (1. + effect.x * .05 * ${inputs.intensity});
pos.y -= 1.;
${outputs.gsplat}.center = pos;
}
else if (${inputs.effectType} == 5) {
vec4 e = disintegrate(localPos, ${inputs.t}, ${inputs.intensity});
${outputs.gsplat}.center = e.xyz;
${outputs.gsplat}.scales = mix(vec3(.01, .01, .01), ${inputs.gsplat}.scales, e.w);
}
else if (${inputs.effectType} == 4) {
vec4 e = flare(localPos, ${inputs.t});
${outputs.gsplat}.center = e.xyz;
${outputs.gsplat}.rgba.rgb = mix(splatColor.rgb, vec3(1.), abs(e.w));
${outputs.gsplat}.rgba.a = mix(splatColor.a, 0.3, abs(e.w));
}
`),
});
// Map effect names to type integers
const effectTypeMap = {
"Electronic": 1,
"Deep Meditation": 2,
"Waves": 3,
"Flare": 4,
"Disintegrate": 5
};
const effectType = effectTypeMap[effectParams.effect] || 1;
gsplat = d.apply({
gsplat,
t: animateT,
effectType: dyno.dynoInt(effectType),
intensity: dyno.dynoFloat(effectParams.intensity)
}).gsplat;
return { gsplat };
}
);
cat.updateGenerator();
// Animation loop
const controls = new SparkControls({ canvas: renderer.domElement });
renderer.setAnimationLoop(function animate(time) {
animateT.value = time / 1000;
cat.updateVersion();
cat.position.set(0, -.7, -2.5);
cat.rotation.set(Math.PI, time / 2000, 0);
controls.update(camera);
renderer.render(scene, camera);
});
</script>
</body>
</html>
+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Splat Texture</title>
<style>
body {
margin: 0;
}
canvas {
touch-action: none;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SparkRenderer, SplatMesh, SparkControls } from "@sparkjsdev/spark";
import GUI from "lil-gui";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.01, 100);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function imgToRgba(img) {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const rgba = new Uint8Array(imageData.data.buffer);
return { rgba, width: img.width, height: img.height };
}
const textureLoader = new THREE.TextureLoader();
const starUrl = await getAssetFileURL("star.png");
const star = imgToRgba((await textureLoader.loadAsync(starUrl)).image);
const heartUrl = await getAssetFileURL("heart.png");
const heart = imgToRgba((await textureLoader.loadAsync(heartUrl)).image);
const splatTexLayers = 32;
const texData = new Uint8Array(4 * star.width * star.height * splatTexLayers);
const texData2 = new Uint8Array(4 * star.width * star.height * splatTexLayers);
for (let z = 0; z < splatTexLayers; z++) {
const t = z / (splatTexLayers - 1);
const starOpacity = Math.max((t - 0.5) / 0.5, 0.0);
const heartOpacity = Math.max((0.5 - t) / 0.5, 0.0);
const gaussOpacity = Math.max(1 - Math.abs(t - 0.5) / 0.5, 0.0);
for (let y = 0; y < star.height; y++) {
for (let x = 0; x < star.width; x++) {
const inIndex = (y * star.width) + x;
const outIndex = ((z * star.height) + y) * star.width + x;
const o4 = outIndex * 4;
const i4 = inIndex * 4;
const deltaX = x / star.width - 0.5;
const deltaY = y / star.height - 0.5;
const dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY) / 0.5;
const inside = (dist < 1.0) ? 255 : 0;
let r = t * star.rgba[i4] + (1 - t) * inside;
let g = t * star.rgba[i4 + 1] + (1 - t) * inside;
let b = t * star.rgba[i4 + 2] + (1 - t) * inside;
let a = t * star.rgba[i4 + 3] + (1 - t) * inside;
texData[o4] = Math.max(0, Math.min(255, Math.round(r)));
texData[o4 + 1] = Math.max(0, Math.min(255, Math.round(g)));
texData[o4 + 2] = Math.max(0, Math.min(255, Math.round(b)));
texData[o4 + 3] = Math.max(0, Math.min(255, Math.round(a)));
r = starOpacity * star.rgba[i4] + heartOpacity * heart.rgba[i4] + inside * gaussOpacity;
g = starOpacity * star.rgba[i4 + 1] + heartOpacity * heart.rgba[i4 + 1] + inside * gaussOpacity;
b = starOpacity * star.rgba[i4 + 2] + heartOpacity * heart.rgba[i4 + 2] + inside * gaussOpacity;
a = starOpacity * star.rgba[i4 + 3] + heartOpacity * heart.rgba[i4 + 3] + inside * gaussOpacity;
texData2[o4] = Math.max(0, Math.min(255, Math.round(r)));
texData2[o4 + 1] = Math.max(0, Math.min(255, Math.round(g)));
texData2[o4 + 2] = Math.max(0, Math.min(255, Math.round(b)));
texData2[o4 + 3] = Math.max(0, Math.min(255, Math.round(a)));
}
}
}
const texture = new THREE.Data3DTexture(texData, star.width, star.height, splatTexLayers);
texture.needsUpdate = true;
const texture2 = new THREE.Data3DTexture(texData2, star.width, star.height, splatTexLayers);
texture2.needsUpdate = true;
const spark = new SparkRenderer({
renderer,
maxStdDev: 1.0,
focalDistance: 4.0,
});
const splatTexture = {
enable: true,
texture: texture,
near: 1.0,
far: 15,
mid: 5,
};
spark.splatTexture = splatTexture;
scene.add(spark);
const gui = new GUI({ title: "DoF settings" });
const selectedTexture = {
selection: "texture",
};
gui.add(selectedTexture, "selection", ["none", "texture", "texture2"]).name("Splat texture").onChange(() => {
splatTexture.enable = true;
if (selectedTexture.selection === "texture") {
splatTexture.texture = texture;
} else if (selectedTexture.selection === "texture2") {
splatTexture.texture = texture2;
} else {
splatTexture.enable = false;
}
});
gui.add(spark, "maxStdDev", 0.1, 3, 0.1);
gui.add(spark, "falloff", 0, 1, 0.01).name("Gaussian falloff");
const apertureSize = {
apertureSize: 0.1,
};
function updateApertureAngle() {
splatTexture.mid = spark.focalDistance;
splatTexture.near = spark.focalDistance / 5.0;
splatTexture.far = spark.focalDistance * 5.0;
if (spark.focalDistance > 0) {
spark.apertureAngle = 2 * Math.atan(0.5 * apertureSize.apertureSize / spark.focalDistance);
} else {
spark.apertureAngle = 0.0;
}
}
updateApertureAngle();
gui.add(spark, "focalDistance", 0, 15, 0.01).name("Focal plane dist")
.onChange(updateApertureAngle);
gui.add(apertureSize, "apertureSize", 0, 1, 0.01).name("Aperture size")
.onChange(updateApertureAngle);
const splatURL = await getAssetFileURL("valley.spz");
const background = new SplatMesh({ url: splatURL });
background.quaternion.set(1, 0, 0, 0);
background.scale.setScalar(0.5);
scene.add(background);
const controls = new SparkControls({ canvas: renderer.domElement });
renderer.setAnimationLoop(function animate(time) {
controls.update(camera);
renderer.render(scene, camera);
});
</script>
</body>
</html>
@@ -0,0 +1,414 @@
import { SparkControls, SplatMesh, dyno, textSplats } from "@sparkjsdev/spark";
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
export async function init({ THREE: _THREE, scene, camera, renderer, spark }) {
const group = new THREE.Group();
scene.add(group);
let disposed = false;
// Basic lights
const ambient = new THREE.AmbientLight(0x404040, 0.6);
group.add(ambient);
const dir = new THREE.DirectionalLight(0xffffff, 1.0);
dir.position.set(5, 10, 5);
dir.castShadow = true;
group.add(dir);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// Camera baseline
camera.position.set(0, 2.5, 7);
camera.lookAt(0, 1, 0);
// WASD + mouse controls
const controls = new SparkControls({ canvas: renderer.domElement });
controls.fpsMovement.moveSpeed = 3.0;
// Uniforms
const animationTime = dyno.dynoFloat(0.0);
const uDropProgress = dyno.dynoFloat(0.0);
const uGravity = dyno.dynoFloat(9.8);
const uBounceDamping = dyno.dynoFloat(0.4);
const uFloorLevel = dyno.dynoFloat(0.0);
const uRandomFactor = dyno.dynoFloat(1.0);
const uReformSpeed = dyno.dynoFloat(2.0);
const uCycleDuration = dyno.dynoFloat(1.0);
const uDropTime = dyno.dynoFloat(0.0);
const uFriction = dyno.dynoFloat(0.98);
const uShrinkSpeed = dyno.dynoFloat(5.0 - 3.0);
const uExplosionStrength = dyno.dynoFloat(4.5);
const uIsReforming = dyno.dynoFloat(0.0);
const uReformTime = dyno.dynoFloat(0.0);
const uReformDuration = dyno.dynoFloat(2.0);
const uIsBirthing = dyno.dynoFloat(0.0);
const uBirthTime = dyno.dynoFloat(0.0);
const uBirthDuration = dyno.dynoFloat(0.5);
function createDeathDynoshader() {
return dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => {
const physicsShader = new dyno.Dyno({
inTypes: {
gsplat: dyno.Gsplat,
time: "float",
dropTime: "float",
dropProgress: "float",
gravity: "float",
bounceDamping: "float",
floorLevel: "float",
randomFactor: "float",
reformSpeed: "float",
cycleDuration: "float",
friction: "float",
shrinkSpeed: "float",
explosionStrength: "float",
isReforming: "float",
reformTime: "float",
reformDuration: "float",
},
outTypes: { gsplat: dyno.Gsplat },
globals: () => [
dyno.unindent(`
mat2 rot(float angle) { float c = cos(angle); float s = sin(angle); return mat2(c, -s, s, c); }
float hash(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); }
vec3 simulatePhysics(vec3 originalPos, float dropTime, float progress, float gravity, float damping, float floorLevel, float randomOffset, float friction, float explosionStrength) {
if (progress <= 0.0) return originalPos;
float timeVariation = hash(originalPos + vec3(42.0)) * 0.2 - 0.1;
float t = max(0.0, dropTime + timeVariation);
vec3 initialVelocity = vec3(
(hash(originalPos + vec3(1.0)) - 0.5) * explosionStrength * (0.3 + hash(originalPos + vec3(10.0)) * 0.4),
abs(hash(originalPos + vec3(3.0))) * explosionStrength * (0.8 + hash(originalPos + vec3(20.0)) * 0.4) + 0.5,
(hash(originalPos + vec3(2.0)) - 0.5) * explosionStrength * (0.3 + hash(originalPos + vec3(30.0)) * 0.4)
);
float frictionDecay = pow(friction, t * 60.0);
vec3 position = originalPos;
position.x += initialVelocity.x * (1.0 - frictionDecay) / (1.0 - friction) / 60.0;
position.z += initialVelocity.z * (1.0 - frictionDecay) / (1.0 - friction) / 60.0;
position.y += initialVelocity.y * t - 0.5 * gravity * t * t;
if (position.y <= floorLevel) {
float bounceTime = t;
float bounceCount = floor(bounceTime * 3.0);
float timeSinceBounce = bounceTime - bounceCount / 3.0;
float bounceHeight = initialVelocity.y * pow(damping, bounceCount) * max(0.0, 1.0 - timeSinceBounce * 3.0);
if (bounceHeight > 0.1) {
position.y = floorLevel + abs(sin(timeSinceBounce * 3.14159 * 3.0)) * bounceHeight;
} else {
position.y = floorLevel;
float scatterFactor = hash(originalPos + vec3(50.0)) * 0.2;
position.x += (hash(originalPos + vec3(60.0)) - 0.5) * scatterFactor;
position.z += (hash(originalPos + vec3(70.0)) - 0.5) * scatterFactor;
}
}
return position;
}
vec3 elegantReform(vec3 currentPos, vec3 originalPos, float reformTime, float duration) {
if (reformTime <= 0.0) return currentPos;
if (reformTime >= duration) return originalPos;
float progress = reformTime / duration;
return mix(currentPos, originalPos, progress);
}
vec3 reformScale(vec3 currentScale, vec3 originalScale, float reformTime, float duration) {
if (reformTime <= 0.0) return currentScale;
if (reformTime >= duration) return originalScale;
float progress = reformTime / duration;
float easeOut = 1.0 - pow(1.0 - progress, 2.0);
return mix(currentScale, originalScale, easeOut);
}
`),
],
statements: ({ inputs, outputs }) =>
dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
vec3 originalPos = ${inputs.gsplat}.center;
vec3 originalScale = ${inputs.gsplat}.scales;
vec3 physicsPos = originalPos;
vec3 currentScale = originalScale;
if (${inputs.dropProgress} > 0.0) {
float randomOffset = hash(originalPos) * ${inputs.randomFactor};
physicsPos = simulatePhysics(originalPos, ${inputs.dropTime}, ${inputs.dropProgress}, ${inputs.gravity}, ${inputs.bounceDamping}, ${inputs.floorLevel}, randomOffset, ${inputs.friction}, ${inputs.explosionStrength});
float factor = exp(-${inputs.dropTime} * ${inputs.shrinkSpeed});
currentScale = mix(originalScale, vec3(0.005), 1.0 - factor);
}
vec3 finalPos = physicsPos;
vec3 finalScale = currentScale;
if (${inputs.isReforming} > 0.5) {
finalPos = elegantReform(physicsPos, originalPos, ${inputs.reformTime}, ${inputs.reformDuration});
finalScale = reformScale(currentScale, originalScale, ${inputs.reformTime}, ${inputs.reformDuration});
}
${outputs.gsplat}.center = finalPos;
${outputs.gsplat}.scales = finalScale;
`),
});
gsplat = physicsShader.apply({
gsplat,
time: animationTime,
dropTime: uDropTime,
dropProgress: uDropProgress,
gravity: uGravity,
bounceDamping: uBounceDamping,
floorLevel: uFloorLevel,
randomFactor: uRandomFactor,
reformSpeed: uReformSpeed,
cycleDuration: uCycleDuration,
friction: uFriction,
shrinkSpeed: uShrinkSpeed,
explosionStrength: uExplosionStrength,
isReforming: uIsReforming,
reformTime: uReformTime,
reformDuration: uReformDuration,
}).gsplat;
return { gsplat };
},
);
}
function createBirthDynoshader() {
return dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => {
const birthShader = new dyno.Dyno({
inTypes: {
gsplat: dyno.Gsplat,
time: "float",
isBirthing: "float",
birthTime: "float",
birthDuration: "float",
},
outTypes: { gsplat: dyno.Gsplat },
globals: () => [
dyno.unindent(`
float hash(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); }
`),
],
statements: ({ inputs, outputs }) =>
dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
vec3 originalPos = ${inputs.gsplat}.center;
vec3 originalScale = ${inputs.gsplat}.scales;
if (${inputs.isBirthing} > 0.5 && ${inputs.birthTime} < ${inputs.birthDuration}) {
float progress = ${inputs.birthTime} / ${inputs.birthDuration};
float birthOffset = hash(originalPos) * 0.1;
float adjusted = clamp((progress - birthOffset / ${inputs.birthDuration}) / (1.0 - birthOffset / ${inputs.birthDuration}), 0.0, 1.0);
float ease = pow(adjusted * adjusted * (3.0 - 2.0 * adjusted), 0.6);
vec3 birthPos = mix(vec3(0.0), originalPos, ease);
vec3 birthScale = mix(vec3(0.0), originalScale, ease);
${outputs.gsplat}.center = birthPos;
${outputs.gsplat}.scales = birthScale;
float alpha = ${inputs.gsplat}.rgba.a * ease;
${outputs.gsplat}.rgba.a = alpha;
}
`),
});
gsplat = birthShader.apply({
gsplat,
time: animationTime,
isBirthing: uIsBirthing,
birthTime: uBirthTime,
birthDuration: uBirthDuration,
}).gsplat;
return { gsplat };
},
);
}
const splatMeshes = {};
let currentSplatName = "penguin";
let nextSplatName = "cat";
async function loadSplats() {
const splatNames = ["penguin.spz", "cat.spz", "woobles.spz"];
for (const splatName of splatNames) {
const splatURL = await getAssetFileURL(splatName);
const mesh = new SplatMesh({ url: splatURL });
await mesh.initialized;
const nameKey = splatName.replace(".spz", "");
mesh.worldModifier = createDeathDynoshader();
mesh.updateGenerator();
mesh.position.set(0, 0, 0);
mesh.rotation.set(Math.PI, 0, 0);
if (nameKey === "woobles") mesh.scale.set(1.7, 2.0, 1.7);
else mesh.scale.set(1, 1, 1);
mesh.visible = nameKey === currentSplatName;
group.add(mesh);
splatMeshes[nameKey] = mesh;
}
}
async function loadTable() {
const tableURL = await getAssetFileURL("table.glb");
const loader = new GLTFLoader();
await new Promise((resolve, reject) => {
loader.load(
tableURL,
(gltf) => {
const tableModel = gltf.scene;
tableModel.position.set(0, -0.5, 0);
tableModel.scale.set(5.5, 5.5, 5.5);
tableModel.rotation.set(0, 0, 0);
tableModel.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
group.add(tableModel);
resolve(tableModel);
},
undefined,
reject,
);
});
}
function _switchToSplat(name) {
for (const m of Object.values(splatMeshes)) {
if (m) m.visible = false;
}
if (splatMeshes[name]) {
splatMeshes[name].visible = true;
currentSplatName = name;
}
}
function getNextSplatName(current) {
const order = ["penguin", "cat", "woobles"];
return order[(order.indexOf(current) + 1) % order.length];
}
const transitionState = {
isTransitioning: false,
transitionTime: 0.0,
transitionDuration: 3.0,
};
function startExplosion() {
if (transitionState.isTransitioning) return;
transitionState.isTransitioning = true;
transitionState.transitionTime = 0.0;
for (const [name, mesh] of Object.entries(splatMeshes)) {
if (mesh) mesh.visible = name === currentSplatName;
}
if (splatMeshes[currentSplatName]) {
splatMeshes[currentSplatName].worldModifier = createDeathDynoshader();
splatMeshes[currentSplatName].updateGenerator();
}
uDropProgress.value = 1.0;
uDropTime.value = 0.0;
uIsReforming.value = 0.0;
}
function startTransition() {
startExplosion();
nextSplatName = getNextSplatName(currentSplatName);
if (splatMeshes[nextSplatName]) {
for (const [name, mesh] of Object.entries(splatMeshes)) {
if (!mesh) continue;
if (name !== currentSplatName && name !== nextSplatName)
mesh.visible = false;
}
splatMeshes[nextSplatName].worldModifier = createBirthDynoshader();
splatMeshes[nextSplatName].updateGenerator();
splatMeshes[nextSplatName].visible = true;
uIsBirthing.value = 1.0;
uBirthTime.value = 0.0;
}
}
function completeTransition() {
uIsBirthing.value = 0.0;
uBirthTime.value = 0.0;
currentSplatName = nextSplatName;
transitionState.isTransitioning = false;
transitionState.transitionTime = 0.0;
}
await Promise.all([loadSplats(), loadTable()]);
if (disposed) return { group, update: () => {}, dispose, setupGUI };
// Instructional text
const instructionsText = textSplats({
text: "WASD + mouse to move\nSPACEBAR: Explosion!",
font: "Arial",
fontSize: 24,
color: new THREE.Color(0xffffff),
textAlign: "center",
lineHeight: 1.3,
});
instructionsText.scale.setScalar(0.15 / 24);
instructionsText.position.set(0, 0.2, 2.5);
group.add(instructionsText);
let totalTime = 0;
const transitionParams = { autoTransition: true };
function onKeyDown(e) {
if (e.code === "Space") {
e.preventDefault();
if (transitionState.isTransitioning) completeTransition();
startTransition();
totalTime = 0;
}
}
window.addEventListener("keydown", onKeyDown);
function update(dt, t) {
animationTime.value = t;
// Update camera controls
controls.update(camera);
if (transitionParams.autoTransition) {
if (!transitionState.isTransitioning) totalTime += dt;
if (!transitionState.isTransitioning && totalTime >= 1.0) {
startTransition();
totalTime = 0;
}
}
if (transitionState.isTransitioning) {
transitionState.transitionTime += dt;
uBirthTime.value = transitionState.transitionTime;
if (transitionState.transitionTime >= transitionState.transitionDuration)
completeTransition();
}
uDropTime.value += dt;
const dying = splatMeshes[currentSplatName];
const birthing = splatMeshes[nextSplatName];
if (transitionState.isTransitioning) {
if (dying) dying.updateVersion();
if (birthing) birthing.updateVersion();
} else {
if (dying) dying.updateVersion();
}
}
function setupGUI(folder) {
const params = { explosionStrength: uExplosionStrength.value };
folder
.add(params, "explosionStrength", 0.0, 10.0, 0.1)
.name("Explosion Strength")
.onChange((v) => {
uExplosionStrength.value = v;
});
folder.add(transitionParams, "autoTransition").name("Auto Transition");
folder
.add({ explode: () => startTransition() }, "explode")
.name("Trigger Explosion");
return folder;
}
function dispose() {
disposed = true;
window.removeEventListener("keydown", onKeyDown);
// Disable controls to avoid interfering with other effects
controls.fpsMovement.enable = false;
controls.pointerControls.enable = false;
scene.remove(group);
}
return { group, update, dispose, setupGUI };
}
@@ -0,0 +1,308 @@
import { SplatMesh, dyno } from "@sparkjsdev/spark";
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
export async function init({ THREE: _THREE, scene, camera, renderer, spark }) {
const group = new THREE.Group();
scene.add(group);
let disposed = false;
const PARAMETERS = {
speedMultiplier: 0.5,
objectRotation: true,
pause: false,
fixedMinScale: false,
waves: 0.5,
cameraRotation: true,
};
const PAUSE_SECONDS = 2.0;
function getTransitionState(t, fadeInTime, fadeOutTime, period) {
const one = dyno.dynoFloat(1.0);
const pauseTime = dyno.dynoFloat(PAUSE_SECONDS);
const cycleTime = dyno.add(one, pauseTime);
const total = dyno.mul(period, cycleTime);
const wrapT = dyno.mod(t, total);
const pos = dyno.mod(wrapT, cycleTime);
const inPause = dyno.greaterThan(pos, one);
const normT = dyno.select(inPause, one, pos);
const fadeIn = dyno.and(
dyno.greaterThan(wrapT, dyno.mul(fadeInTime, cycleTime)),
dyno.lessThan(wrapT, dyno.mul(dyno.add(fadeInTime, one), cycleTime)),
);
const fadeOut = dyno.and(
dyno.greaterThan(wrapT, dyno.mul(fadeOutTime, cycleTime)),
dyno.lessThan(wrapT, dyno.mul(dyno.add(fadeOutTime, one), cycleTime)),
);
return { inTransition: dyno.or(fadeIn, fadeOut), isFadeIn: fadeIn, normT };
}
function contractionDyno(centerGLSL) {
return new dyno.Dyno({
inTypes: {
gsplat: dyno.Gsplat,
inTransition: "bool",
fadeIn: "bool",
t: "float",
gt: "float",
objectIndex: "int",
fixedMinScale: "bool",
waves: "float",
},
outTypes: { gsplat: dyno.Gsplat },
globals: () => [
dyno.unindent(`
float hash13(vec3 p3) { p3 = fract(p3 * .1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
float hash11(float p) { p = fract(p * .1031); p += dot(p, p + 33.33); return fract(p * p); }
float fadeInOut(float t) { return abs(mix(-1., 1., t)); }
${centerGLSL}
float applyBrightness(float t) { return .5 + fadeInOut(t) * .5; }
vec3 applyCenter(vec3 center, float t, float id, int idx, float waves) {
int next = (idx + 1) % 3;
vec3 cNext = getCenterOfMass(next);
vec3 cOwn = getCenterOfMass(idx);
float f = fadeInOut(t);
float v = .5 + hash11(id) * 2.;
vec3 p = t < .5 ? mix(cNext, center, pow(f, v)) : mix(cOwn, center, pow(f, v));
return p + length(sin(p*2.5)) * waves * (1.-f)*smoothstep(0.5,0.,t) * 2.;
}
vec3 applyScale(vec3 s, float t, bool fixedMin) { return mix(fixedMin ? vec3(.02) : s * .2, s, pow(fadeInOut(t), 3.)); }
float applyOpacity(float t, float gt, int idx) {
float p = float(${PAUSE_SECONDS});
float c = 1.0 + p;
float tot = 3.0 * c;
float w = mod(gt + p + .5, tot);
int cur = int(floor(w / c));
return cur == idx ? .1+fadeInOut(t) : 0.0;
}
`),
],
statements: ({ inputs, outputs }) =>
dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
${outputs.gsplat}.center = applyCenter(${inputs.gsplat}.center, ${inputs.t}, float(${inputs.gsplat}.index), ${inputs.objectIndex}, ${inputs.waves});
${outputs.gsplat}.scales = applyScale(${inputs.gsplat}.scales, ${inputs.t}, ${inputs.fixedMinScale});
${outputs.gsplat}.rgba.a *= applyOpacity(${inputs.t}, ${inputs.gt}, ${inputs.objectIndex});
${outputs.gsplat}.rgba.rgb *= applyBrightness(${inputs.t});
`),
});
}
function getTransitionModifier(
inTrans,
fadeIn,
t,
idx,
gt,
centerGLSL,
fixedMinScale,
waves,
) {
const dyn = contractionDyno(centerGLSL);
return dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => ({
gsplat: dyn.apply({
gsplat,
inTransition: inTrans,
fadeIn,
t,
gt,
objectIndex: idx,
fixedMinScale,
waves,
}).gsplat,
}),
);
}
async function loadGLB(file, isEnv = false) {
const url = await getAssetFileURL(file);
const loader = new GLTFLoader();
const gltf = await new Promise((res, rej) =>
loader.load(url, res, undefined, rej),
);
gltf.scene.traverse((child) => {
if (child.isMesh && child.material) {
const mat = new THREE.MeshBasicMaterial({
color: child.material.color,
map: child.material.map,
});
if (isEnv) {
mat.side = THREE.BackSide;
if (mat.map) {
mat.map.mapping = THREE.EquirectangularReflectionMapping;
mat.map.colorSpace = THREE.LinearSRGBColorSpace;
mat.map.needsUpdate = true;
}
}
child.material = mat;
}
});
return gltf.scene;
}
const time = dyno.dynoFloat(0.0);
const splatFiles = ["woobles.spz", "dessert.spz", "robot-head.spz"];
const skyFile = "dali-env.glb";
const env = await loadGLB(skyFile, true);
if (!disposed) group.add(env);
const meshes = [];
const period = dyno.dynoFloat(splatFiles.length);
const positions = [
new THREE.Vector3(-5, -2.2, -3),
new THREE.Vector3(5, -2.5, 0),
new THREE.Vector3(0, 1.5, 2),
];
for (let i = 0; i < splatFiles.length; i++) {
const url = await getAssetFileURL(splatFiles[i]);
const m = new SplatMesh({ url });
await m.initialized;
m.position.copy(positions[i]);
m.rotateX(Math.PI);
if (!disposed) group.add(m);
meshes.push(m);
}
const centers = meshes.map((m) => {
const box = new THREE.Box3();
m.packedSplats.forEachSplat((_, c) => {
box.expandByPoint(c);
});
const localCenter = box.getCenter(new THREE.Vector3());
localCenter.y = -localCenter.y;
return localCenter.add(m.position);
});
const centerGLSL = `
vec3 getCenterOfMass(int idx) {
if (idx == 0) return vec3(${centers[0].x}, ${centers[0].y}, ${centers[0].z});
if (idx == 1) return vec3(${centers[1].x}, ${centers[1].y}, ${centers[1].z});
if (idx == 2) return vec3(${centers[2].x}, ${centers[2].y}, ${centers[2].z});
return vec3(0.0);
}
`;
meshes.forEach((m, i) => {
const { inTransition, isFadeIn, normT } = getTransitionState(
time,
dyno.dynoFloat(i),
dyno.dynoFloat((i + 1) % splatFiles.length),
period,
);
m.worldModifier = getTransitionModifier(
inTransition,
isFadeIn,
normT,
dyno.dynoInt(i),
time,
centerGLSL,
dyno.dynoBool(PARAMETERS.fixedMinScale),
dyno.dynoFloat(PARAMETERS.waves),
);
m.updateGenerator();
});
function updateCamera() {
const cTime = 1 + PAUSE_SECONDS;
const tot = meshes.length * cTime;
const w = (time.value + PAUSE_SECONDS) % tot;
const cur = Math.floor(w / cTime);
const nxt = (cur + 1) % meshes.length;
const tr = w / cTime - cur;
const s = tr * tr * (3 - 2 * tr);
const tgt = new THREE.Vector3().lerpVectors(
meshes[cur].position,
meshes[nxt].position,
s ** 5,
);
const radius = 4 + Math.abs(s - 0.5) ** 2 * 20;
let x;
let z;
if (PARAMETERS.cameraRotation) {
const angle = -time.value * 0.5;
x = Math.cos(angle) * radius;
z = Math.sin(angle) * radius;
} else {
x = 0;
z = -radius;
}
const frm = tgt.clone().add(new THREE.Vector3(x, 2, z));
camera.position.copy(frm);
camera.lookAt(tgt);
}
function update(dt, _t) {
if (!PARAMETERS.pause) {
time.value += dt * PARAMETERS.speedMultiplier;
if (PARAMETERS.objectRotation) {
for (const m of meshes) {
m.rotation.y += dt * PARAMETERS.speedMultiplier * 2;
}
}
}
updateCamera();
}
function setupGUI(folder) {
folder.add(PARAMETERS, "speedMultiplier", 0, 1, 0.01);
folder.add(PARAMETERS, "objectRotation");
folder.add(PARAMETERS, "pause");
folder.add(PARAMETERS, "cameraRotation");
folder.add(PARAMETERS, "fixedMinScale").onChange(() => {
meshes.forEach((m, i) => {
const { inTransition, isFadeIn, normT } = getTransitionState(
time,
dyno.dynoFloat(i),
dyno.dynoFloat((i + 1) % splatFiles.length),
dyno.dynoFloat(splatFiles.length),
);
m.worldModifier = getTransitionModifier(
inTransition,
isFadeIn,
normT,
dyno.dynoInt(i),
time,
centerGLSL,
dyno.dynoBool(PARAMETERS.fixedMinScale),
dyno.dynoFloat(PARAMETERS.waves),
);
m.updateGenerator();
});
});
folder.add(PARAMETERS, "waves", 0, 1, 0.01).onChange(() => {
meshes.forEach((m, i) => {
const { inTransition, isFadeIn, normT } = getTransitionState(
time,
dyno.dynoFloat(i),
dyno.dynoFloat((i + 1) % splatFiles.length),
dyno.dynoFloat(splatFiles.length),
);
m.worldModifier = getTransitionModifier(
inTransition,
isFadeIn,
normT,
dyno.dynoInt(i),
time,
centerGLSL,
dyno.dynoBool(PARAMETERS.fixedMinScale),
dyno.dynoFloat(PARAMETERS.waves),
);
m.updateGenerator();
});
});
return folder;
}
function dispose() {
disposed = true;
scene.remove(group);
}
return { group, update, dispose, setupGUI };
}
@@ -0,0 +1,228 @@
import { SplatMesh, dyno } from "@sparkjsdev/spark";
import * as THREE from "three";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
export async function init({ THREE: _THREE, scene, camera, renderer, spark }) {
const group = new THREE.Group();
scene.add(group);
let disposed = false;
// Camera baseline for Morph effect
camera.position.set(0, 2.2, 6.5);
camera.lookAt(0, 1.0, 0);
const PARAMETERS = {
speedMultiplier: 1.0,
rotation: true,
pause: false,
staySeconds: 1.5,
transitionSeconds: 2.0,
randomRadius: 1.3,
};
const time = dyno.dynoFloat(0.0);
// Tres splats de comida
const splatFiles = [
"branzino-amarin.spz",
"pad-thai.spz",
"primerib-tamos.spz",
];
function morphDyno() {
return new dyno.Dyno({
inTypes: {
gsplat: dyno.Gsplat,
gt: "float",
objectIndex: "int",
stay: "float",
trans: "float",
numObjects: "int",
randomRadius: "float",
offsetY: "float",
},
outTypes: { gsplat: dyno.Gsplat },
globals: () => [
dyno.unindent(`
vec3 hash3(int n) {
float x = float(n);
return fract(sin(vec3(x, x + 1.0, x + 2.0)) * 43758.5453123);
}
float ease(float x) { return x*x*(3.0 - 2.0*x); }
vec3 randPos(int splatIndex, float radius) {
// Uniform disk sampling on XZ plane
vec3 h = hash3(splatIndex);
float theta = 6.28318530718 * h.x;
float r = radius * sqrt(h.y);
return vec3(r * cos(theta), 0.0, r * sin(theta));
}
`),
],
statements: ({ inputs, outputs }) =>
dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
float stay = ${inputs.stay};
float trans = ${inputs.trans};
float cycle = stay + trans;
float tot = float(${inputs.numObjects}) * cycle;
float w = mod(${inputs.gt}, tot);
int cur = int(floor(w / cycle));
int nxt = (cur + 1) % ${inputs.numObjects};
float local = mod(w, cycle);
bool inTrans = local > stay;
float uPhase = inTrans ? clamp((local - stay) / trans, 0.0, 1.0) : 0.0;
bool phaseScatter = uPhase < 0.5;
float s = phaseScatter ? (uPhase / 0.5) : ((uPhase - 0.5) / 0.5);
int idx = ${inputs.objectIndex};
vec3 rp = randPos(int(${inputs.gsplat}.index), ${inputs.randomRadius});
rp.y -= ${inputs.offsetY};
vec3 rpMid = mix(${inputs.gsplat}.center, rp, 0.7);
float alpha = 0.0;
vec3 pos = ${inputs.gsplat}.center;
vec3 origScale = ${inputs.gsplat}.scales;
vec3 small = ${inputs.gsplat}.scales*.2;
if (idx == cur) {
if (!inTrans) {
alpha = 1.0;
pos = ${inputs.gsplat}.center;
${outputs.gsplat}.scales = origScale;
} else if (phaseScatter) {
alpha = 1.0 - ease(s)*.5;
pos = mix(${inputs.gsplat}.center, rpMid, ease(s));
${outputs.gsplat}.scales = mix(origScale, small, ease(s));
} else {
alpha = 0.0;
pos = rpMid;
${outputs.gsplat}.scales = small;
}
} else if (idx == nxt) {
if (!inTrans) {
alpha = 0.0;
pos = rpMid;
${outputs.gsplat}.scales = small;
} else if (phaseScatter) {
alpha = 0.0;
pos = rpMid;
${outputs.gsplat}.scales = small;
} else {
alpha = max(ease(s), 0.5);
pos = mix(rpMid, ${inputs.gsplat}.center, ease(s));
${outputs.gsplat}.scales = mix(small, origScale, ease(s));
}
} else {
alpha = 0.0;
pos = ${inputs.gsplat}.center;
${outputs.gsplat}.scales = origScale;
}
pos.y += ${inputs.offsetY};
${outputs.gsplat}.center = pos;
${outputs.gsplat}.rgba.a = ${inputs.gsplat}.rgba.a * alpha;
`),
});
}
function getMorphModifier(
gt,
idx,
stay,
trans,
numObjects,
randomRadius,
offsetY,
) {
const dyn = morphDyno();
return dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => ({
gsplat: dyn.apply({
gsplat,
gt,
objectIndex: idx,
stay,
trans,
numObjects,
randomRadius,
offsetY,
}).gsplat,
}),
);
}
const meshes = [];
const numObjectsDyn = dyno.dynoInt(splatFiles.length);
const stayDyn = dyno.dynoFloat(PARAMETERS.staySeconds);
const transDyn = dyno.dynoFloat(PARAMETERS.transitionSeconds);
const radiusDyn = dyno.dynoFloat(PARAMETERS.randomRadius);
const OFFSETS_Y = [
dyno.dynoFloat(0.0),
dyno.dynoFloat(0.3),
dyno.dynoFloat(0.0),
];
for (let i = 0; i < splatFiles.length; i++) {
const url = await getAssetFileURL(splatFiles[i]);
const mesh = new SplatMesh({ url });
await mesh.initialized;
// Orientación base similar a otros efectos
mesh.rotateX(Math.PI);
mesh.position.set(0, 0, 0);
mesh.scale.set(1.5, 1.5, 1.5);
if (!disposed) group.add(mesh);
meshes.push(mesh);
}
// Asignar modificadores de morph (hold → scatter → morph)
meshes.forEach((m, i) => {
m.worldModifier = getMorphModifier(
time,
dyno.dynoInt(i),
stayDyn,
transDyn,
numObjectsDyn,
radiusDyn,
OFFSETS_Y[i] ?? dyno.dynoFloat(0.0),
);
m.updateGenerator();
});
function update(dt, _t) {
if (!PARAMETERS.pause) {
time.value += dt * PARAMETERS.speedMultiplier;
for (const m of meshes) {
if (PARAMETERS.rotation) {
m.rotation.y += dt * PARAMETERS.speedMultiplier;
}
// Ensure dyno uniform updates are applied even without rotation
m.updateVersion();
}
}
}
function setupGUI(folder) {
folder.add(PARAMETERS, "speedMultiplier", 0.1, 3.0, 0.01);
folder.add(PARAMETERS, "rotation");
folder.add(PARAMETERS, "pause");
folder.add(PARAMETERS, "staySeconds", 0.2, 5.0, 0.05).onChange((v) => {
stayDyn.value = v;
});
folder
.add(PARAMETERS, "transitionSeconds", 1.0, 3.0, 0.05)
.onChange((v) => {
transDyn.value = v;
});
folder.add(PARAMETERS, "randomRadius", 1, 5.0, 0.1).onChange((v) => {
radiusDyn.value = v;
});
return folder;
}
function dispose() {
disposed = true;
scene.remove(group);
}
return { group, update, dispose, setupGUI };
}
@@ -0,0 +1,278 @@
import { SplatMesh, dyno } from "@sparkjsdev/spark";
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
export async function init({ THREE: _THREE, scene, camera, renderer, spark }) {
const group = new THREE.Group();
scene.add(group);
let disposed = false;
// Params and uniforms
const PARAMETERS = {
splatCoverage: 1.0,
spereRadius: 1.0,
sphereHeight: 2.0,
speedMultiplier: 1.0,
rotation: true,
pause: false,
};
const time = dyno.dynoFloat(0.0);
// Camera baseline
const _prevPos = camera.position.clone();
const prevLook = new THREE.Vector3();
camera.getWorldDirection(prevLook);
const SPHERICAL_TARGET = new THREE.Vector3(0, 2, 0);
camera.position.set(5, 4, 7);
camera.lookAt(SPHERICAL_TARGET);
const skyFile = "dali-env.glb";
const sceneFile = "dali-table.glb";
const splatFiles = ["penguin.spz", "dessert.spz", "woobles.spz"];
async function loadDelitGLB(filename, isEnv = false) {
const url = await getAssetFileURL(filename);
const gltfLoader = new GLTFLoader();
const gltf = await new Promise((resolve, reject) => {
gltfLoader.load(url, resolve, undefined, reject);
});
const root = gltf.scene;
root.traverse((child) => {
if (child.isMesh && child.material) {
const original = child.material;
const basic = new THREE.MeshBasicMaterial();
if (original.color) basic.color.copy(original.color);
if (original.map) basic.map = original.map;
if (isEnv) {
basic.side = THREE.BackSide;
if (basic.map) {
basic.map.mapping = THREE.EquirectangularReflectionMapping;
basic.map.colorSpace = THREE.LinearSRGBColorSpace;
basic.map.needsUpdate = true;
}
}
child.material = basic;
}
});
return root;
}
function getTransitionState(t, fadeInTime, fadeOutTime, period) {
const dynoOne = dyno.dynoFloat(1.0);
const wrapT = dyno.mod(t, period);
const normT = dyno.mod(t, dynoOne);
const isFadeIn = dyno.and(
dyno.greaterThan(wrapT, fadeInTime),
dyno.lessThan(wrapT, dyno.add(fadeInTime, dynoOne)),
);
const isFadeOut = dyno.and(
dyno.greaterThan(wrapT, fadeOutTime),
dyno.lessThan(wrapT, dyno.add(fadeOutTime, dynoOne)),
);
const inTransition = dyno.or(isFadeIn, isFadeOut);
return { inTransition, isFadeIn, normT };
}
function contractionDyno() {
return new dyno.Dyno({
inTypes: {
gsplat: dyno.Gsplat,
inTransition: "bool",
fadeIn: "bool",
t: "float",
splatScale: "float",
spereRadius: "float",
sphereHeight: "float",
},
outTypes: { gsplat: dyno.Gsplat },
globals: () => [
dyno.unindent(`
vec3 applyCenter(vec3 center, float t, float spereRadius, float sphereHeight) {
float heightModifier = 0.5 + 0.5 * pow(abs(1.0 - 2.0*t), 0.2);
vec3 targetCenter = vec3(0.0, heightModifier * sphereHeight, 0.0);
vec3 dir = normalize(center - targetCenter);
vec3 targetPoint = targetCenter + dir * spereRadius;
if (t < 0.25 || t > 0.75) {
return center;
} else if (t < 0.45) {
return mix(center, targetPoint, pow((t - 0.25) * 5.0, 4.0));
} else if (t < 0.55) {
float churn = 0.1;
float transitionT = (t - 0.45) * 10.0;
float angle = transitionT * 2.0 * PI;
vec3 rotvec = vec3(sin(angle), 0.0, cos(angle));
float strength = sin(transitionT * PI);
return targetPoint + cross(dir, rotvec) * churn * strength;
} else {
return mix(targetPoint, center, pow((t - 0.55) * 5.0, 4.0));
}
}
vec3 applyScale(vec3 scales, float t, float targetScale) {
vec3 targetScales = targetScale * vec3(1.0);
if (t < 0.25) return scales;
else if (t < 0.45) return mix(scales, targetScales, pow((t - 0.25) * 5.0, 2.0));
else if (t < 0.55) return targetScales;
else if (t < 0.75) return mix(targetScales, scales, pow((t - 0.55) * 5.0, 2.0));
else return scales;
}
float applyOpacity(float opacity, float t, bool fadeIn) {
if (fadeIn) {
if (t < 0.4) return 0.0;
else if (t < 0.6) return mix(0.0, opacity, pow((t - 0.4) * 5.0, 2.0));
else return opacity;
} else {
if (t < 0.4) return opacity;
else if (t < 0.6) return mix(opacity, 0.0, pow((t - 0.4) * 5.0, 2.0));
else return 0.0;
}
}
`),
],
statements: ({ inputs, outputs }) =>
dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
${outputs.gsplat}.center = applyCenter(${inputs.gsplat}.center, ${inputs.t}, ${inputs.spereRadius}, ${inputs.sphereHeight});
${outputs.gsplat}.scales = applyScale(${inputs.gsplat}.scales, ${inputs.t}, ${inputs.splatScale});
if (${inputs.inTransition}) {
${outputs.gsplat}.rgba.a = applyOpacity(${inputs.gsplat}.rgba.a, ${inputs.t}, ${inputs.fadeIn});
} else {
${outputs.gsplat}.rgba.a = 0.0;
}
`),
});
}
function getTransitionModifier(
inTransition,
fadeIn,
t,
splatScale,
spereRadius,
sphereHeight,
) {
const contraction = contractionDyno();
return dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => {
gsplat = contraction.apply({
gsplat,
inTransition,
fadeIn,
t,
splatScale,
spereRadius,
sphereHeight,
}).gsplat;
return { gsplat };
},
);
}
async function morphableSplatMesh(
assetName,
time,
fadeInTime,
fadeOutTime,
period,
splatCoverage,
spereRadius,
sphereHeight,
) {
const url = await getAssetFileURL(assetName);
const splatMesh = new SplatMesh({ url });
await splatMesh.initialized;
const splatScale = dyno.div(
dyno.mul(splatCoverage, spereRadius),
dyno.dynoFloat(splatMesh.packedSplats.numSplats / 1000.0),
);
const { inTransition, isFadeIn, normT } = getTransitionState(
time,
fadeInTime,
fadeOutTime,
period,
);
splatMesh.worldModifier = getTransitionModifier(
inTransition,
isFadeIn,
normT,
splatScale,
spereRadius,
sphereHeight,
);
splatMesh.updateGenerator();
splatMesh.quaternion.set(1, 0, 0, 0);
return splatMesh;
}
// Load env and assets
const sky = await loadDelitGLB(skyFile, true);
if (!disposed) group.add(sky);
const table = await loadDelitGLB(sceneFile, false);
const sceneScale = 3.5;
table.scale.set(sceneScale, sceneScale, sceneScale);
table.position.set(-1, 0, -0.8);
if (!disposed) group.add(table);
const period = dyno.dynoFloat(splatFiles.length);
const spereRadiusDyno = dyno.dynoFloat(PARAMETERS.spereRadius);
const splatCoverageDyno = dyno.dynoFloat(PARAMETERS.splatCoverage);
const sphereHeightDyno = dyno.dynoFloat(PARAMETERS.sphereHeight);
const meshes = [];
for (let i = 0; i < splatFiles.length; i++) {
const mesh = await morphableSplatMesh(
splatFiles[i],
time,
dyno.dynoFloat(i),
dyno.dynoFloat((i + 1) % splatFiles.length),
period,
splatCoverageDyno,
spereRadiusDyno,
sphereHeightDyno,
);
if (!disposed) group.add(mesh);
meshes.push(mesh);
}
function update(dt, _t) {
if (!PARAMETERS.pause) {
time.value += dt * 0.5 * PARAMETERS.speedMultiplier;
if (PARAMETERS.rotation) {
for (const m of meshes) {
m.rotation.y += dt * PARAMETERS.speedMultiplier;
}
}
}
// Keep camera centered on spherical target
camera.lookAt(SPHERICAL_TARGET);
}
function setupGUI(folder) {
folder.add(PARAMETERS, "spereRadius", 0.1, 8.0, 0.01).onChange((v) => {
spereRadiusDyno.value = v;
});
folder.add(PARAMETERS, "sphereHeight", -1.0, 4.0, 0.01).onChange((v) => {
sphereHeightDyno.value = v;
});
folder.add(PARAMETERS, "splatCoverage", 0.1, 2.0, 0.01).onChange((v) => {
splatCoverageDyno.value = v;
});
folder.add(PARAMETERS, "speedMultiplier", 0.25, 4.0, 0.01);
folder.add(PARAMETERS, "rotation");
folder.add(PARAMETERS, "pause");
return folder;
}
function dispose() {
disposed = true;
// Remove group
scene.remove(group);
// No global listeners here; controls are managed by main
}
return { group, update, dispose, setupGUI };
}
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Splat Transitions</title>
<style>
html, body { margin: 0; height: 100%; width: 100%; background-color: black; }
#canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; outline: none; touch-action: none; }
.loading { position: absolute; top: 12px; left: 12px; color: white; font-family: Arial, sans-serif; font-size: 12px; opacity: .8; }
</style>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
</head>
<body>
<div class="loading" id="loading">Loading...</div>
<canvas id="canvas" tabindex="0"></canvas>
<script type="module" src="./main.js"></script>
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
import { SparkRenderer } from "@sparkjsdev/spark";
import { GUI } from "lil-gui";
import * as THREE from "three";
// Central renderer/scene/camera shared by effects
const canvas = document.getElementById("canvas");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
renderer.setClearColor(0x000000, 1);
const scene = new THREE.Scene();
const spark = new SparkRenderer({ renderer });
scene.add(spark);
const camera = new THREE.PerspectiveCamera(
50,
canvas.clientWidth / canvas.clientHeight,
0.01,
2000,
);
camera.position.set(0, 3, 8);
camera.lookAt(0, 0, 0);
scene.add(camera);
// Resize handling
function handleResize() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener("resize", handleResize);
// GUI
const gui = new GUI();
const params = { Effect: "Spherical" };
const effectFiles = {
Spherical: () => import("./effects/spheric.js"),
Explosion: () => import("./effects/explosion.js"),
Flow: () => import("./effects/flow.js"),
Morph: () => import("./effects/morph.js"),
};
let active = null; // { api, group }
let last = 0;
let effectFolder = null; // GUI folder for current effect
let switchCounter = 0; // guards concurrent effect switches
async function switchEffect(name) {
const myToken = ++switchCounter;
const loading = document.getElementById("loading");
loading.textContent = `Loading ${name}...`;
loading.style.display = "block";
// Dispose previous
if (active) {
try {
active.api.dispose?.();
} catch {}
if (active.group) scene.remove(active.group);
active = null;
}
// Destroy previous GUI folder to avoid accumulation
if (effectFolder) {
try {
effectFolder.destroy();
} catch {}
effectFolder = null;
}
const loader = effectFiles[name];
if (!loader) return;
const preChildren = new Set(scene.children);
const mod = await loader();
if (myToken !== switchCounter) {
// A newer switch started; ignore this one
return;
}
const context = { THREE, scene, camera, renderer, spark };
const api = await mod.init(context);
if (myToken !== switchCounter) {
try {
api.dispose?.();
} catch {}
// Remove any children added during this init
for (const child of [...scene.children]) {
if (!preChildren.has(child)) scene.remove(child);
}
return;
}
if (api.group) scene.add(api.group);
active = { api, group: api.group };
// Setup a per-effect GUI folder if exposed
if (api.setupGUI) {
effectFolder = gui.addFolder(name);
api.setupGUI(effectFolder);
}
loading.style.display = "none";
// Give focus back to the canvas so keyboard controls work immediately
try {
canvas.focus();
} catch {}
}
gui.add(params, "Effect", Object.keys(effectFiles)).onChange(switchEffect);
// Animation loop
renderer.setAnimationLoop((timeMs) => {
const t = timeMs * 0.001;
const dt = t - (last || t);
last = t;
if (active?.api?.update) active.api.update(dt, t);
renderer.render(scene, camera);
});
// Kickoff
switchEffect(params.Effect);
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Stochastic Rendering</title>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SparkRenderer, SparkControls, SplatMesh, VRButton } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// Make a local frame of reference that we can move to control
// the camera, or as a frame of reference in WebXR mode
const localFrame = new THREE.Group();
scene.add(localFrame);
// Lower the splat rendering width to sqrt(5) std devs for more performance
const spark = new SparkRenderer({ renderer, maxStdDev: Math.sqrt(5) });
spark.defaultView.stochastic = true;
localFrame.add(spark);
localFrame.add(camera);
const splatURL = await getAssetFileURL("valley.spz");
const background = new SplatMesh({ url: splatURL });
background.quaternion.set(1, 0, 0, 0);
background.scale.setScalar(0.5);
scene.add(background);
const vrButton = VRButton.createButton(renderer);
if (vrButton) {
// WebXR is available, so show the button
document.body.appendChild(vrButton);
}
let lastCameraPos = new THREE.Vector3(0, 0, 0);
const controls = new SparkControls({ canvas: renderer.domElement });
renderer.setAnimationLoop(function animate(time, xrFrame) {
controls.update(localFrame);
// This is a hack to make a "local" frame work reliably across
// Quest 3 and Vision Pro. Any big discontinuity in the camera
// results in a reverse shift of the local frame to compensate.
if (lastCameraPos.distanceTo(camera.position) > 0.5) {
localFrame.position.copy(camera.position).multiplyScalar(-1);
}
lastCameraPos.copy(camera.position);
renderer.render(scene, camera);
});
</script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5441 0.789888C15.11 0.789888 15.5943 0.991228 15.997 1.39391C16.3997 1.79659 16.6014 2.28124 16.6021 2.84788L16.6021 4.90587C16.6021 5.19742 16.5033 5.44163 16.3057 5.63851C16.1081 5.83539 15.8639 5.93418 15.5731 5.93486C15.2822 5.93555 15.0376 5.83676 14.8394 5.63851C14.6411 5.44026 14.5427 5.19604 14.5441 4.90587L14.5441 2.84788L2.19612 2.84788L2.19612 4.90587C2.19612 5.19741 2.09734 5.44163 1.89977 5.63851C1.70221 5.83539 1.45799 5.93417 1.16713 5.93486C0.876265 5.93555 0.631708 5.83676 0.433455 5.63851C0.235202 5.44026 0.136761 5.19604 0.138133 4.90587L0.138133 2.84788C0.138133 2.28193 0.339473 1.79727 0.742153 1.39391C1.14483 0.990541 1.62949 0.789201 2.19612 0.789887L14.5441 0.789888ZM9.39909 13.2922L11.3285 11.3628C11.5343 11.157 11.7785 11.0582 12.0611 11.0665C12.3437 11.0747 12.5883 11.182 12.7948 11.3885C12.9834 11.5943 13.0822 11.8344 13.0911 12.1088C13.1 12.3832 13.0013 12.6233 12.7948 12.8291L9.09039 16.5335C8.98749 16.6364 8.87601 16.7091 8.75596 16.7517C8.63592 16.7942 8.50729 16.8158 8.37009 16.8165C8.23289 16.8172 8.10427 16.7956 7.98422 16.7517C7.86417 16.7078 7.75269 16.635 7.6498 16.5335L3.94541 12.8291C3.73962 12.6233 3.64083 12.3832 3.64906 12.1088C3.6573 11.8344 3.75608 11.5943 3.94541 11.3885C4.15121 11.1827 4.39543 11.0754 4.67806 11.0665C4.96069 11.0575 5.20525 11.1563 5.41173 11.3628L7.3411 13.2922L7.3411 5.93486C7.3411 5.64331 7.43988 5.39875 7.63745 5.20119C7.83502 5.00362 8.07923 4.90518 8.37009 4.90587C8.66096 4.90655 8.90517 5.00534 9.10274 5.20222C9.3003 5.3991 9.39909 5.64331 9.39909 5.93486L9.39909 13.2922Z" fill="#F46A71"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+361
View File
@@ -0,0 +1,361 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Viewer</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.container {
display: flex;
align-items: center;
width: 100%;
height: 100vh;
background-color: rgb(43, 41, 40);
margin: 0;
padding: 0;
}
.drop-container {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.spark-canvas {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
.invisible {
opacity: 0;
pointer-events: none;
}
.logo {
position: fixed;
top: 20px;
left: 30px;
z-index: 1;
}
.logo img {
width: 40px;
}
.hero-logo {
text-align: center;
margin-bottom: 2rem;
}
.hero-logo img {
width: 350px;
height: auto;
margin-bottom: 1rem;
}
.button,
.url-input,
.drop-zone {
width: 100%;
max-width: 300px;
margin: 0.5rem 0;
padding: 0.75rem;
border-radius: 6px;
font-size: 1rem;
text-align: center;
}
.drop-zone {
max-width: 500px;
padding-top: 4rem;
padding-bottom: 4rem;
}
.button {
background-color: transparent;
color: #ccc;
border: none;
cursor: pointer;
}
.button:hover {
background-color: rgb(47, 47, 47);
}
.button.upload {
display: flex;
align-items: center;
justify-content: center;
color: rgb(113, 113, 113);
line-height: 1em;
}
.upload-icon {
margin-right: 10px;
height: 1em;
}
.url-input {
background-color: rgb(47, 47, 47);
border: none;
color: #fff;
}
.drop-zone {
border: 1px dashed #666;
color: rgb(89, 89, 89);
background-color: rgb(47, 47, 47);
}
.drop-zone.dragover {
border: 1px dashed #aaa;
color: rgb(170, 170, 170);
}
@media (min-width: 600px) {
.button,
.url-input,
.drop-zone {
font-size: 1.1rem;
}
}
.hidden {
display: none;
}
.spz-button {
position: fixed;
top: 30px;
right: 30px;
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.5em 1.5em;
background-color: rgb(60, 60, 60);
border: 0px solid rgb(60, 60, 60);
border-radius: 0.5em;
color: #ff6c6c;
font-family: system-ui, sans-serif;
font-size: 0.9em;
cursor: pointer;
z-index: 1000;
}
.spz-button:hover {
background-color: rgb(90, 90, 90);
}
.spz-button .icon {
display: flex;
align-items: center;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SplatMesh, transcodeSpz, SparkControls } from "@sparkjsdev/spark";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
const sparkCanvasEl = document.querySelector(".spark-canvas");
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.01, 1000);
camera.position.set(0, 0, 1);
const renderer = new THREE.WebGLRenderer({canvas: sparkCanvasEl});
const width = sparkCanvasEl.clientWidth;
const height = sparkCanvasEl.clientHeight;
var fileName;
var fileBytes;
renderer.setSize(width, height, false);
debugger;
const controls = new SparkControls({ canvas: sparkCanvasEl });
// const orbitControls = new OrbitControls(camera, renderer.domElement);
// orbitControls.target.set(0, 0, 0);
// orbitControls.minDistance = 0.1;
// orbitControls.maxDistance = 10;
function resize() {
const width = window.innerWidth;
const height = window.innerHeight;
// Only resize if necessary
const canvas = renderer.domElement;
const needResize = canvas.width !== width || canvas.height !== height;
if (needResize) {
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
}
window.addEventListener('resize', () => {
setTimeout(function () { resize(); }, 100);
});
renderer.setAnimationLoop(function animate(time) {
resize();
// orbitControls.update();
controls.update(camera);
renderer.render(scene, camera);
});
// Handle drag and drop files.
const dropZoneEl = document.querySelector('.container');
const dropUIEl = document.querySelector('.drop-zone');
const onDragover = function (e) {
e.preventDefault();
dropUIEl.classList.add('dragover');
};
const onDragLeave = function () {
dropUIEl.classList.remove('dragover');
};
const onDrop = function (e) {
e.preventDefault();
dropUIEl.classList.remove('dragover');
dropUIEl.classList.add('drop');
const files = e.dataTransfer.files;
if (files.length > 0) {
loadSplatFile(files[0]);
}
};
dropZoneEl.addEventListener('dragover', onDragover);
sparkCanvasEl.addEventListener('dragover', onDragover);
dropZoneEl.addEventListener('dragleave', onDragLeave);
sparkCanvasEl.addEventListener('dragleave', onDragLeave);
dropZoneEl.addEventListener('drop', onDrop);
sparkCanvasEl.addEventListener('drop', onDrop);
const fileInput = document.querySelector('#file-input');
fileInput.onchange = function(event) {
loadSplatFile(event.target.files[0]);
};
async function loadSplatFile(splatFile) {
fileBytes = new Uint8Array(await splatFile.arrayBuffer());
fileName = splatFile.name;
setSplatFile({ fileBytes: fileBytes.slice(), fileName });
}
var loadedSplat;
function setSplatFile(init) {
if (loadedSplat) { scene.remove(loadedSplat); }
loadedSplat = new SplatMesh(init);
loadedSplat.quaternion.set(1, 0, 0, 0);
scene.add(loadedSplat);
document.querySelector('.container').classList.add('hidden');
document.querySelector('.canvas-container').classList.remove('invisible');
}
let params = new URLSearchParams(document.location.search);
let splatURL = params.get("url");
if (splatURL) { loadSplatURL(splatURL); }
function loadSplatURL(splatURL) {
fileName = splatURL.split("/").pop().split("?")[0];
document.querySelector('.container').classList.add('hidden');
document.querySelector('.canvas-container').classList.remove('invisible');
setSplatFile({ url: splatURL });
}
const urlFormEl = document.querySelector('.url-form');
const urlInputEl = document.querySelector('.url-input');
urlFormEl.addEventListener('submit', function handleEnterKey(event) {
event.preventDefault();
const url = new URL(window.location);
const splatURL = urlInputEl.value;
loadSplatURL(urlInputEl.value);
url.searchParams.set('url', splatURL);
history.pushState(null, '', url);
});
document.querySelector('.spz-button').addEventListener('click', async function () {
const fileInfo = {
fileBytes: fileBytes,
pathOrUrl: fileName,
};
const transcodeInfo = {
inputs: [fileInfo],
maxSh: 3,
fractionalBits: 12,
opacityThreshold: 0
};
const transcode = await transcodeSpz(transcodeInfo);
const blob = new Blob([transcode.fileBytes], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName.split(".")[0] + ".spz";
a.click();
URL.revokeObjectURL(url);
});
</script>
<div class="canvas-container invisible">
<div class="logo">
<a href="/"><img src="spark-logo.svg" alt="Spark Logo" /></a>
</div>
<button class="spz-button">
<span class="icon" aria-hidden="true">
<img src="download.svg" width="18" height="18" alt="" />
</span>
<span class="label">.spz</span>
</button>
</button>
<canvas class="spark-canvas"></canvas>
</div>
<div class="container">
<div class="drop-container">
<div class="hero-logo">
<a href="/"><img src="spark-viewer.svg" alt="Spark Viewer Logo" /></a>
</div>
<div class="drop-zone">Drag and drop a splat file here</div>
<label for="file-input" class="button upload"><img class="upload-icon" src="upload-icon.svg" alt="upload" />Choose file</label>
<input id="file-input" class="hidden" accept=".ply,.spz,.splat,.ksplat,.zip" type="file" />
<form class="url-form">
<input class="url-input" type="text" placeholder="Copy splat URL" />
</form>
</div>
</div>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
<svg width="55" height="77" viewBox="0 0 55 77" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.364746 41.3045H54.1078L52.0792 48.4476C34.7792 48.5904 31.004 69.8796 47.6276 71.7945C47.6276 71.7945 47.6276 71.7945 46.6639 76.3802L8.31542 76.4032L8.31542 67.6169C8.31542 67.6169 22.9457 67.2167 20.3691 59.6814C17.7925 52.146 5.23803 57.1973 0.364746 41.3045Z" fill="#C44A50"/>
<path d="M28.0834 0.204662C45.5362 10.0241 33.3218 22.3397 38.5056 24.9169C43.6894 27.4941 43.9288 17.8826 43.9288 17.8826C47.6245 24.2738 48.8106 30.0871 45.4387 37.6978H14.4434C10.7441 32.0044 13.7014 22.8095 20.77 17.7636C29.2993 11.5575 32.6131 7.60866 28.0834 0.204662Z" fill="#C44A50"/>
</svg>

After

Width:  |  Height:  |  Size: 689 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

Some files were not shown because too many files have changed in this diff Show More