+
+
+ Scenes
+ Reset camera
+
+
+ All scenes
+
+
+
+
+
+
Clear data
diff --git a/src/app-info.js b/src/app-info.js
index a41a82e..47eec42 100644
--- a/src/app-info.js
+++ b/src/app-info.js
@@ -1 +1 @@
-export const APP_VERSION = '0.3.0';
+export const APP_VERSION = '0.4.0';
diff --git a/src/fluid.js b/src/fluid.js
index 30e0691..f710f1a 100644
--- a/src/fluid.js
+++ b/src/fluid.js
@@ -70,6 +70,9 @@ if (!gl || !ext.formatRGBA || !ext.formatRG || !ext.formatR) {
canvas,
supported: false,
clear () {},
+ getConfig () { return {}; },
+ randomSplats () {},
+ setConfig () {},
update () {}
};
}
@@ -1021,7 +1024,7 @@ function applyEmitters (emitters) {
const dx = correctDeltaX(Number(emitter.dx) || 0) * config.SPLAT_FORCE;
const dy = correctDeltaY(Number(emitter.dy) || 0) * config.SPLAT_FORCE;
const color = emitter.color || { r: 0, g: 0, b: 0 };
- splat(x, y, dx, dy, color);
+ splat(x, y, dx, dy, color, emitter.radius);
}
}
@@ -1226,13 +1229,17 @@ function blur (target, temp, iterations) {
}
}
-function splat (x, y, dx, dy, color) {
+function splat (x, y, dx, dy, color, radiusOverride) {
+ const splatRadius = Number.isFinite(Number(radiusOverride))
+ ? Number(radiusOverride)
+ : config.SPLAT_RADIUS;
+
splatProgram.bind();
gl.uniform1i(splatProgram.uniforms.uTarget, velocity.read.attach(0));
gl.uniform1f(splatProgram.uniforms.aspectRatio, canvas.width / canvas.height);
gl.uniform2f(splatProgram.uniforms.point, x, y);
gl.uniform3f(splatProgram.uniforms.color, dx, dy, 0.0);
- gl.uniform1f(splatProgram.uniforms.radius, correctRadius(config.SPLAT_RADIUS / 100.0));
+ gl.uniform1f(splatProgram.uniforms.radius, correctRadius(splatRadius / 100.0));
blit(velocity.write);
velocity.swap();
@@ -1312,6 +1319,85 @@ function hashCode (s) {
return hash;
};
+function setConfig (next = {}) {
+ let reinitialize = false;
+ let updateDisplayKeywords = false;
+
+ for (const [key, rawValue] of Object.entries(next)) {
+ if (!(key in config)) continue;
+
+ const current = config[key];
+ const value = typeof current === 'boolean'
+ ? Boolean(rawValue)
+ : Number(rawValue);
+
+ if (typeof current !== 'boolean' && !Number.isFinite(value)) continue;
+ if (current === value) continue;
+
+ config[key] = value;
+
+ if (key === 'SIM_RESOLUTION' || key === 'DYE_RESOLUTION')
+ reinitialize = true;
+ if (key === 'SHADING' || key === 'BLOOM' || key === 'SUNRAYS')
+ updateDisplayKeywords = true;
+ }
+
+ if (reinitialize)
+ initFramebuffers();
+ if (updateDisplayKeywords)
+ updateKeywords();
+}
+
+function getConfig () {
+ return { ...config };
+}
+
+function randomSplats (amount = parseInt(Math.random() * 20) + 5) {
+ const count = Math.max(0, Math.floor(Number(amount) || 0));
+
+ for (let i = 0; i < count; i++) {
+ const color = generateColor();
+ color.r *= 10.0;
+ color.g *= 10.0;
+ color.b *= 10.0;
+ const x = Math.random();
+ const y = Math.random();
+ const dx = 1000 * (Math.random() - 0.5);
+ const dy = 1000 * (Math.random() - 0.5);
+ splat(x, y, dx, dy, color);
+ }
+
+ render(null);
+}
+
+function generateColor () {
+ let c = HSVtoRGB(Math.random(), 1.0, 1.0);
+ c.r *= 0.15;
+ c.g *= 0.15;
+ c.b *= 0.15;
+ return c;
+}
+
+function HSVtoRGB (h, s, v) {
+ let r, g, b, i, f, p, q, t;
+ i = Math.floor(h * 6);
+ f = h * 6 - i;
+ p = v * (1 - s);
+ q = v * (1 - f * s);
+ t = v * (1 - (1 - f) * s);
+
+ switch (i % 6) {
+ case 0: r = v, g = t, b = p; break;
+ case 1: r = q, g = v, b = p; break;
+ case 2: r = p, g = v, b = t; break;
+ case 3: r = p, g = q, b = v; break;
+ case 4: r = t, g = p, b = v; break;
+ case 5: r = v, g = p, b = q; break;
+ }
+
+ return { r, g, b };
+}
+
function clear () {
gl.disable(gl.BLEND);
const targets = [
@@ -1338,6 +1424,9 @@ return {
canvas,
supported: true,
clear,
+ getConfig,
+ randomSplats,
+ setConfig,
update
};
}
diff --git a/src/main.js b/src/main.js
index 87e831e..d6d81e7 100644
--- a/src/main.js
+++ b/src/main.js
@@ -42,10 +42,20 @@ const ui = {
debug: $('#debug-content'), clear: $('#clear'), resetCamera: $('#reset-camera'), scenes: $('#scenes'),
sidebarToggle: $('#sidebar-toggle'), viewerTitle: $('.viewer-title'), connectionQrVisibility: $('.connection-qr-visibility'),
connectionSection: $('#connection-section'), connectionAdvanced: $('#connection-advanced'), connectionAdvancedSummary: $('#connection-advanced-summary'),
- displaySection: $('#display-section'), displayAdvanced: $('#display-advanced'), displaySectionSummary: $('#display-section-summary'),
debugSection: $('#debug-section'), visualsSection: $('#visuals-section'), visualsSectionSummary: $('#visuals-section-summary'), appVersion: $('#app-version'),
showClusters: $('#show-clusters'), showPoints: $('#show-points'), showScene: $('#show-scene'), showZones: $('#show-zones'), showVectors: $('#show-vectors'),
- visualEnabled: $('#visual-enabled')
+ visualPreset: $('#visual-preset'), visualEnabled: $('#visual-enabled'),
+ visualDyeResolution: $('#visual-dye-resolution'), visualSimResolution: $('#visual-sim-resolution'),
+ visualDensityDissipation: $('#visual-density-dissipation'), visualDensityDissipationValue: $('#visual-density-dissipation-value'),
+ visualVelocityDissipation: $('#visual-velocity-dissipation'), visualVelocityDissipationValue: $('#visual-velocity-dissipation-value'),
+ visualPressure: $('#visual-pressure'), visualPressureValue: $('#visual-pressure-value'),
+ visualCurl: $('#visual-curl'), visualCurlValue: $('#visual-curl-value'),
+ visualSplatRadius: $('#visual-splat-radius'), visualSplatRadiusValue: $('#visual-splat-radius-value'),
+ visualShading: $('#visual-shading'), visualColorful: $('#visual-colorful'), visualPaused: $('#visual-paused'),
+ visualBloom: $('#visual-bloom'), visualBloomIntensity: $('#visual-bloom-intensity'), visualBloomIntensityValue: $('#visual-bloom-intensity-value'),
+ visualBloomThreshold: $('#visual-bloom-threshold'), visualBloomThresholdValue: $('#visual-bloom-threshold-value'),
+ visualSunrays: $('#visual-sunrays'), visualSunraysWeight: $('#visual-sunrays-weight'), visualSunraysWeightValue: $('#visual-sunrays-weight-value'),
+ visualRandomSplats: $('#visual-random-splats'), visualSplatInput: $('#visual-splat-input'), visualSplatInputSummary: $('#visual-splat-input-summary')
};
ui.appVersion.textContent = `Version ${APP_VERSION}`;
@@ -53,6 +63,10 @@ ui.appVersion.textContent = `Version ${APP_VERSION}`;
const viewer = createViewer($('#canvas-host'));
const visuals = createVisuals($('#canvas-host'));
const debug = createDebugPanel(ui.summary, ui.debug);
+const visualPresets = visuals.getPresets();
+const defaultVisualPreset = visuals.getPreset('default').options;
+
+populateVisualPresetOptions();
let disconnectCleanupTimer;
let cameraPreferenceSaveTimer;
@@ -135,7 +149,7 @@ function savePreferences() {
const value = {
connection: localConnectionPreferences,
- display: {
+ data: {
clusters: ui.showClusters.checked,
points: ui.showPoints.checked,
sceneBounds: ui.showScene.checked,
@@ -143,9 +157,7 @@ function savePreferences() {
vectors: ui.showVectors.checked,
scene: preferredSceneAddress
},
- visuals: {
- enabled: ui.visualEnabled.checked
- },
+ visuals: visualSettingsFromUi(),
ui: {
sidebarHidden: ui.app.classList.contains('sidebar-hidden'),
sidebarWidth: Number.isFinite(sidebarWidth) ? sidebarWidth : SIDEBAR_MAX_WIDTH,
@@ -153,10 +165,8 @@ function savePreferences() {
sections: {
connection: ui.connectionSection.open,
connectionAdvanced: ui.connectionAdvanced.open,
- display: ui.displaySection.open,
- displayAdvanced: ui.displayAdvanced.open,
visuals: ui.visualsSection.open,
- debug: ui.debugSection.open
+ data: ui.debugSection.open
}
}
};
@@ -186,7 +196,11 @@ function restorePreferences() {
const savedPreferences = loadPreferences();
const connection = isObject(savedPreferences.connection) ? savedPreferences.connection : {};
const sharedConnection = readConnectionOptionsFromUrl(window.location.href);
- const display = isObject(savedPreferences.display) ? savedPreferences.display : {};
+ const dataPreferences = isObject(savedPreferences.data)
+ ? savedPreferences.data
+ : isObject(savedPreferences.display)
+ ? savedPreferences.display
+ : {};
const visualPreferences = isObject(savedPreferences.visuals) ? savedPreferences.visuals : {};
const uiPreferences = isObject(savedPreferences.ui) ? savedPreferences.ui : {};
const sectionPreferences = isObject(uiPreferences.sections) ? uiPreferences.sections : {};
@@ -198,37 +212,37 @@ function restorePreferences() {
localConnectionPreferences = normalizeConnectionOptions(getConnectionSettings());
applyConnectionSettings(resolveConnectionOptions(localConnectionPreferences, sharedConnection));
- if (typeof display.clusters === 'boolean') ui.showClusters.checked = display.clusters;
- if (typeof display.points === 'boolean') ui.showPoints.checked = display.points;
- if (typeof display.sceneBounds === 'boolean') {
- ui.showScene.checked = display.sceneBounds;
- } else if (typeof display.zones === 'boolean') {
+ if (typeof dataPreferences.clusters === 'boolean') ui.showClusters.checked = dataPreferences.clusters;
+ if (typeof dataPreferences.points === 'boolean') ui.showPoints.checked = dataPreferences.points;
+ if (typeof dataPreferences.sceneBounds === 'boolean') {
+ ui.showScene.checked = dataPreferences.sceneBounds;
+ } else if (typeof dataPreferences.zones === 'boolean') {
// Backward compatibility with the previous combined "Scene & zones" toggle.
- ui.showScene.checked = display.zones;
+ ui.showScene.checked = dataPreferences.zones;
}
- if (typeof display.zones === 'boolean') ui.showZones.checked = display.zones;
- if (typeof display.vectors === 'boolean') ui.showVectors.checked = display.vectors;
- if (typeof display.scene === 'string' && display.scene) preferredSceneAddress = display.scene;
-
- if (typeof visualPreferences.enabled === 'boolean') {
- ui.visualEnabled.checked = visualPreferences.enabled;
+ if (typeof dataPreferences.zones === 'boolean') ui.showZones.checked = dataPreferences.zones;
+ if (typeof dataPreferences.vectors === 'boolean') ui.showVectors.checked = dataPreferences.vectors;
+ if (typeof dataPreferences.scene === 'string' && dataPreferences.scene) {
+ preferredSceneAddress = dataPreferences.scene;
}
+ writeVisualSettings({
+ ...defaultVisualPreset,
+ ...visualPreferences
+ });
+
const sidebarWidth = Number(uiPreferences.sidebarWidth);
if (Number.isFinite(sidebarWidth)) setSidebarWidth(sidebarWidth);
setSidebarHidden(uiPreferences.sidebarHidden === true, false);
- for (const [key, element] of [
+ for (const [key, element, legacyKey] of [
['connection', ui.connectionSection],
['connectionAdvanced', ui.connectionAdvanced],
- ['display', ui.displaySection],
- ['displayAdvanced', ui.displayAdvanced],
['visuals', ui.visualsSection],
- ['debug', ui.debugSection]
+ ['data', ui.debugSection, 'debug']
]) {
- if (typeof sectionPreferences[key] === 'boolean') {
- element.open = sectionPreferences[key];
- }
+ const saved = sectionPreferences[key] ?? sectionPreferences[legacyKey];
+ if (typeof saved === 'boolean') element.open = saved;
}
viewer.setCameraView(uiPreferences.cameraView);
@@ -393,10 +407,7 @@ function zoneNameForAddress(address) {
return parts.at(-1) || address || '—';
}
-function updateDisplaySectionSummary() {
- ui.displaySectionSummary.textContent = ui.scenes.selectedOptions[0]?.textContent
- ?? (ui.scenes.disabled ? 'No scenes' : 'All scenes');
-}
+
function updateConnectionAdvancedSummary() {
const protocolLabel = ui.protocol.selectedOptions[0]?.textContent?.split(' — ')[0]
@@ -422,7 +433,6 @@ function syncSceneSelector() {
|| scenes.some((scene) => scene.getAddress() === requested);
ui.scenes.value = stillAvailable ? requested : 'all';
ui.scenes.disabled = scenes.length === 0;
- updateDisplaySectionSummary();
}
function setSetup(root) {
@@ -527,22 +537,118 @@ function toggleSimulation() {
}
function applyVisibility() {
- const visibility = {
+ viewer.setVisibility({
clusters: ui.showClusters.checked,
points: ui.showPoints.checked,
scene: ui.showScene.checked,
zones: ui.showZones.checked,
vectors: ui.showVectors.checked
+ });
+}
+
+function populateVisualPresetOptions() {
+ ui.visualPreset.innerHTML = [
+ ...visualPresets.map(({ name, label }) => (
+ `
${escapeOption(label)} `
+ )),
+ '
Custom '
+ ].join('');
+}
+
+function visualSettingsFromUi() {
+ return {
+ enabled: ui.visualEnabled.checked,
+ splatInput: ui.visualSplatInput.value,
+ dyeResolution: Number(ui.visualDyeResolution.value),
+ simResolution: Number(ui.visualSimResolution.value),
+ densityDissipation: Number(ui.visualDensityDissipation.value),
+ velocityDissipation: Number(ui.visualVelocityDissipation.value),
+ pressure: Number(ui.visualPressure.value),
+ curl: Number(ui.visualCurl.value),
+ splatRadius: Number(ui.visualSplatRadius.value),
+ shading: ui.visualShading.checked,
+ colorful: ui.visualColorful.checked,
+ paused: ui.visualPaused.checked,
+ bloom: ui.visualBloom.checked,
+ bloomIntensity: Number(ui.visualBloomIntensity.value),
+ bloomThreshold: Number(ui.visualBloomThreshold.value),
+ sunrays: ui.visualSunrays.checked,
+ sunraysWeight: Number(ui.visualSunraysWeight.value)
};
- viewer.setVisibility(visibility);
- visuals.setVisibility(visibility);
}
-function applyVisualOptions() {
- visuals.setOptions({ enabled: ui.visualEnabled.checked });
- ui.visualsSectionSummary.textContent = ui.visualEnabled.checked ? 'Fluid' : 'Off';
+function writeVisualSettings(settings) {
+ ui.visualEnabled.checked = settings.enabled;
+ ui.visualSplatInput.value = settings.splatInput;
+ ui.visualDyeResolution.value = String(settings.dyeResolution);
+ ui.visualSimResolution.value = String(settings.simResolution);
+ ui.visualDensityDissipation.value = String(settings.densityDissipation);
+ ui.visualVelocityDissipation.value = String(settings.velocityDissipation);
+ ui.visualPressure.value = String(settings.pressure);
+ ui.visualCurl.value = String(settings.curl);
+ ui.visualSplatRadius.value = String(settings.splatRadius);
+ ui.visualShading.checked = settings.shading;
+ ui.visualColorful.checked = settings.colorful;
+ ui.visualPaused.checked = settings.paused;
+ ui.visualBloom.checked = settings.bloom;
+ ui.visualBloomIntensity.value = String(settings.bloomIntensity);
+ ui.visualBloomThreshold.value = String(settings.bloomThreshold);
+ ui.visualSunrays.checked = settings.sunrays;
+ ui.visualSunraysWeight.value = String(settings.sunraysWeight);
+ updateVisualRangeOutputs();
+}
+
+function updateVisualRangeOutputs() {
+ for (const [input, output] of [
+ [ui.visualDensityDissipation, ui.visualDensityDissipationValue],
+ [ui.visualVelocityDissipation, ui.visualVelocityDissipationValue],
+ [ui.visualPressure, ui.visualPressureValue],
+ [ui.visualCurl, ui.visualCurlValue],
+ [ui.visualSplatRadius, ui.visualSplatRadiusValue],
+ [ui.visualBloomIntensity, ui.visualBloomIntensityValue],
+ [ui.visualBloomThreshold, ui.visualBloomThresholdValue],
+ [ui.visualSunraysWeight, ui.visualSunraysWeightValue]
+ ]) {
+ output.value = input.value;
+ output.textContent = input.value;
+ }
+}
+
+function visualSettingsEqual(left, right) {
+ return Object.keys(defaultVisualPreset).every((key) => {
+ const a = left[key];
+ const b = right[key];
+ return typeof a === 'number' && typeof b === 'number'
+ ? Math.abs(a - b) < 1e-9
+ : a === b;
+ });
+}
+
+function updateVisualPresetState() {
+ const settings = visualSettingsFromUi();
+ const matchedPreset = visualPresets.find(({ name }) => {
+ const preset = visuals.getPreset(name);
+ return preset && visualSettingsEqual(settings, preset.options);
+ });
+ const customOption = ui.visualPreset.querySelector('option[value="custom"]');
+ const isCustom = !matchedPreset;
+
+ customOption.hidden = !isCustom;
+ customOption.disabled = !isCustom;
+ ui.visualPreset.value = matchedPreset?.name ?? 'custom';
+ ui.visualsSectionSummary.textContent = settings.enabled
+ ? (matchedPreset?.label ?? 'Custom')
+ : 'Off';
+
+ ui.visualSplatInputSummary.textContent =
+ ui.visualSplatInput.selectedOptions[0]?.textContent ?? 'Point clouds';
}
+function applyVisualOptions() {
+ updateVisualRangeOutputs();
+ visuals.setOptions(visualSettingsFromUi());
+ updateVisualPresetState();
+}
viewer.setCameraChangeHandler(scheduleCameraPreferenceSave);
ui.connect.addEventListener('click', toggleConnection);
@@ -554,7 +660,6 @@ ui.resetCamera.addEventListener('click', () => {
});
ui.scenes.addEventListener('change', () => {
preferredSceneAddress = ui.scenes.value;
- updateDisplaySectionSummary();
savePreferences();
renderSelectedScenes();
});
@@ -562,8 +667,6 @@ ui.scenes.addEventListener('change', () => {
for (const section of [
ui.connectionSection,
ui.connectionAdvanced,
- ui.displaySection,
- ui.displayAdvanced,
ui.visualsSection
]) {
section.addEventListener('toggle', savePreferences);
@@ -755,17 +858,58 @@ ui.downsample.addEventListener('change', () => {
savePreferences();
}));
-ui.visualEnabled.addEventListener('change', () => {
+const visualRangeInputs = [
+ ui.visualDensityDissipation,
+ ui.visualVelocityDissipation,
+ ui.visualPressure,
+ ui.visualCurl,
+ ui.visualSplatRadius,
+ ui.visualBloomIntensity,
+ ui.visualBloomThreshold,
+ ui.visualSunraysWeight
+];
+
+const visualChangeInputs = [
+ ui.visualEnabled,
+ ui.visualDyeResolution,
+ ui.visualSimResolution,
+ ui.visualShading,
+ ui.visualColorful,
+ ui.visualPaused,
+ ui.visualBloom,
+ ui.visualSunrays,
+ ui.visualSplatInput
+];
+
+for (const input of visualRangeInputs) {
+ input.addEventListener('input', applyVisualOptions);
+ input.addEventListener('change', savePreferences);
+}
+
+for (const input of visualChangeInputs) {
+ input.addEventListener('change', () => {
+ applyVisualOptions();
+ savePreferences();
+ });
+}
+
+ui.visualPreset.addEventListener('change', () => {
+ const preset = visuals.getPreset(ui.visualPreset.value);
+ if (!preset) return;
+
+ writeVisualSettings(preset.options);
applyVisualOptions();
savePreferences();
});
+ui.visualRandomSplats.addEventListener('click', () => {
+ visuals.randomSplats();
+});
function syncPreferencesToUi() {
restorePreferences();
applyVisibility();
applyVisualOptions();
updateConnectionAdvancedSummary();
- updateDisplaySectionSummary();
syncPanelCamera(false);
refreshConnectionQr();
}
diff --git a/src/styles.css b/src/styles.css
index ef35ef2..0e2bd10 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -49,7 +49,7 @@ button, input, select { font: inherit; }
#canvas-host .data-visuals {
position: absolute;
- z-index: 2;
+ z-index: 1;
inset: 0;
pointer-events: none;
}
@@ -58,6 +58,13 @@ button, input, select { font: inherit; }
background: #000;
}
+#canvas-host .debug-viewer-canvas {
+ position: absolute;
+ z-index: 2;
+ inset: 0;
+ background: transparent;
+}
+
.viewer-overlay {
position: absolute;
top: 0;
@@ -68,6 +75,7 @@ button, input, select { font: inherit; }
align-items: flex-start;
justify-content: space-between;
gap: 24px;
+ z-index: 8;
pointer-events: none;
background: linear-gradient(to bottom, rgba(9, 11, 15, .72), transparent);
}
@@ -427,6 +435,86 @@ button.subtle-button.active {
-webkit-tap-highlight-color: transparent;
}
+
+.visual-preset-field {
+ display: grid;
+ gap: 6px;
+ margin-bottom: var(--section-space-relaxed);
+}
+
+.visual-master-toggle {
+ margin-bottom: var(--section-space);
+}
+
+.visual-controls {
+ gap: var(--section-space-relaxed);
+}
+
+.visual-control-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--section-space);
+}
+
+.visual-range-control {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 5px 10px;
+ margin: 0;
+ color: #aab3c2;
+ font-size: 10px;
+ font-weight: 500;
+}
+
+.visual-range-control > span {
+ min-width: 0;
+}
+
+.visual-range-control output {
+ min-width: 34px;
+ color: #8791a2;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 9.5px;
+ text-align: right;
+}
+
+.visual-range-control input[type="range"] {
+ grid-column: 1 / -1;
+ width: 100%;
+ height: 16px;
+ min-height: 16px;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ outline: none;
+ background: transparent;
+ box-shadow: none;
+ accent-color: var(--accent);
+}
+
+.visual-range-control input[type="range"]:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: 3px;
+}
+
+.visual-toggle-grid {
+ margin-top: 2px;
+}
+
+.visual-action-button {
+ width: 100%;
+}
+
+.data-overlay-controls {
+ margin-bottom: var(--section-space-relaxed);
+}
+
+.data-panel-actions {
+ margin-top: var(--section-space-relaxed);
+}
+
.debug-content { display: grid; gap: 9px; }
.empty-state { padding: 22px 14px; border: 1px dashed #303746; border-radius: 8px; color: #778194; text-align: center; font-size: 11px; line-height: 1.5; }
diff --git a/src/viewer.js b/src/viewer.js
index d52b3cc..673f614 100644
--- a/src/viewer.js
+++ b/src/viewer.js
@@ -33,23 +33,26 @@ const PALETTE = [
export function createViewer(host) {
const scene = new THREE.Scene();
- scene.background = new THREE.Color(0x0c0f14);
+ scene.background = null;
scene.fog = new THREE.FogExp2(0x0c0f14, 0.014);
const camera = new THREE.PerspectiveCamera(48, 1, 0.02, 500);
- const renderer = new THREE.WebGLRenderer({ antialias: true });
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.domElement.classList.add('debug-viewer-canvas');
host.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
configureControls(controls);
+ const sceneHelperGroup = namedGroup(scene, 'Scene helpers');
const grid = new THREE.GridHelper(100, 100, 0x4d5668, 0x252b35);
grid.material.transparent = true;
grid.material.opacity = 0.52;
grid.material.depthWrite = false;
- scene.add(grid, new THREE.AxesHelper(1));
+ sceneHelperGroup.add(grid, new THREE.AxesHelper(1));
const setupGroup = namedGroup(scene, 'Augmenta scene setup');
const clusterGroup = namedGroup(scene, 'Tracked clusters');
@@ -543,6 +546,7 @@ export function createViewer(host) {
visibility.zones = zones;
visibility.vectors = vectors;
+ sceneHelperGroup.visible = scene;
clusterGroup.visible = clusters;
pointGroup.visible = points;
vectorGroup.visible = vectors;
diff --git a/src/visuals.js b/src/visuals.js
index 0dd26d5..25bfe74 100644
--- a/src/visuals.js
+++ b/src/visuals.js
@@ -7,14 +7,54 @@ const DEFAULT_SCENE = {
maxY: 4
};
+const REFERENCE_DEFAULTS = Object.freeze({
+ enabled: true,
+ splatInput: 'point-clouds',
+ dyeResolution: 1024,
+ simResolution: 128,
+ densityDissipation: 1,
+ velocityDissipation: 0.2,
+ pressure: 0.8,
+ curl: 30,
+ splatRadius: 0.25,
+ shading: true,
+ colorful: true,
+ paused: false,
+ bloom: true,
+ bloomIntensity: 0.8,
+ bloomThreshold: 0.6,
+ sunrays: true,
+ sunraysWeight: 1
+});
+
const MAX_TOTAL_EMITTERS = 16;
const MAX_EMITTERS_PER_CLOUD = 8;
const MAX_QUEUED_EMITTERS = 32;
const MAX_POINT_DELTA_UV = 0.08;
const MIN_POINT_DELTA_UV = 0.00001;
-const COLOR_UPDATE_SPEED = 10;
const MIN_FRAME_DT = 1 / 120;
const MAX_FRAME_DT = 1 / 10;
+const COLOR_UPDATE_SPEED = 10;
+const BOUNDING_BOX_RADIUS_MIN = 0.01;
+const BOUNDING_BOX_RADIUS_MAX = 1;
+
+const FLUID_CONFIG_KEYS = Object.freeze({
+ dyeResolution: 'DYE_RESOLUTION',
+ simResolution: 'SIM_RESOLUTION',
+ densityDissipation: 'DENSITY_DISSIPATION',
+ velocityDissipation: 'VELOCITY_DISSIPATION',
+ pressure: 'PRESSURE',
+ curl: 'CURL',
+ splatRadius: 'SPLAT_RADIUS',
+ shading: 'SHADING',
+ colorful: 'COLORFUL',
+ paused: 'PAUSED',
+ bloom: 'BLOOM',
+ bloomIntensity: 'BLOOM_INTENSITY',
+ bloomThreshold: 'BLOOM_THRESHOLD',
+ sunrays: 'SUNRAYS',
+ sunraysWeight: 'SUNRAYS_WEIGHT'
+});
export function createVisuals(host) {
const canvas = document.createElement('canvas');
@@ -24,12 +64,19 @@ export function createVisuals(host) {
const fluid = safeCreateFluid(canvas);
const views = new Map();
+ const defaultOptions = effectiveDefaults(fluid.getConfig?.());
+ const presets = Object.freeze({
+ default: Object.freeze({
+ label: 'Default',
+ options: Object.freeze({ ...defaultOptions })
+ })
+ });
let scene = { ...DEFAULT_SCENE };
+ let options = { ...defaultOptions };
let pendingEmitters = [];
let lastAnimationTime = performance.now();
let colorUpdateTimer = 0;
- let enabled = true;
function renderSetup(root, selectedSceneAddress) {
const scenes = [];
@@ -44,11 +91,13 @@ export function createVisuals(host) {
function renderFrame(frame) {
const sceneAddress = frame.getSceneInfo?.().getAddress?.() || '';
const now = performance.now();
- const clouds = [];
+ const objects = [];
const active = new Set();
frame.getObjects?.().forEach((object, index) => {
- if (!object.hasPointCloud?.()) return;
+ const hasCluster = Boolean(object.hasCluster?.());
+ const hasPointCloud = Boolean(object.hasPointCloud?.());
+ if (!hasCluster && !hasPointCloud) return;
const id = object.getID?.();
const uuid = object.getUUID?.();
@@ -61,15 +110,18 @@ export function createVisuals(host) {
views.set(key, view);
}
- const points = object.getPointCloud?.().getPointsData?.();
- if (!points?.length) return;
-
- let velocity = [0, 0, 0];
- if (object.hasCluster?.()) {
- velocity = vector3(object.getCluster().getVelocity?.(), velocity);
- }
-
- clouds.push({ view, points, velocity, now });
+ const cluster = hasCluster ? object.getCluster() : undefined;
+ const cloud = hasPointCloud ? object.getPointCloud() : undefined;
+ const points = cloud?.getPointsData?.();
+
+ objects.push({
+ view,
+ points,
+ centroid: cluster ? vector3(cluster.getCentroid?.(), undefined) : undefined,
+ size: cluster ? vector3(cluster.getBoundingBoxSize?.(), undefined) : undefined,
+ velocity: cluster ? vector3(cluster.getVelocity?.(), [0, 0, 0]) : [0, 0, 0],
+ now
+ });
});
for (const [key] of views) {
@@ -78,15 +130,12 @@ export function createVisuals(host) {
}
}
- if (!enabled || !clouds.length) return;
+ if (!options.enabled) return;
- const perCloudBudget = emitterBudget(clouds.length);
- const emitters = [];
-
- for (const cloud of clouds) {
- emitters.push(...emittersForCloud(cloud, perCloudBudget, scene));
- }
+ const sources = emitterSources(objects, options.splatInput);
+ if (!sources.length) return;
+ const emitters = collectEmitters(sources, options.splatInput, scene);
if (!emitters.length) return;
pendingEmitters.push(...emitters);
@@ -101,8 +150,8 @@ export function createVisuals(host) {
updateColors(dt);
- canvas.hidden = !enabled || !fluid.supported;
- if (enabled && fluid.supported) {
+ canvas.hidden = !options.enabled || !fluid.supported;
+ if (options.enabled && fluid.supported) {
const emitters = pendingEmitters;
pendingEmitters = [];
fluid.update(dt, emitters);
@@ -114,6 +163,8 @@ export function createVisuals(host) {
}
function updateColors(dt) {
+ if (!options.colorful) return;
+
colorUpdateTimer += dt * COLOR_UPDATE_SPEED;
if (colorUpdateTimer < 1) return;
@@ -125,6 +176,41 @@ export function createVisuals(host) {
}
}
+ function setOptions(next = {}) {
+ const previousEnabled = options.enabled;
+ const previousInput = options.splatInput;
+
+ options = {
+ ...options,
+ ...pickVisualOptions(next)
+ };
+
+ fluid.setConfig?.(fluidConfigFromOptions(next));
+
+ if (previousInput !== options.splatInput) resetInputTracking();
+ if (previousEnabled !== options.enabled) reset();
+ }
+
+ function getOptions() {
+ return { ...options };
+ }
+
+ function getPreset(name) {
+ const preset = presets[name];
+ return preset ? { label: preset.label, options: { ...preset.options } } : undefined;
+ }
+
+ function getPresets() {
+ return Object.entries(presets).map(([name, preset]) => ({
+ name,
+ label: preset.label
+ }));
+ }
+
+ function randomSplats() {
+ fluid.randomSplats?.();
+ }
+
function clearTracking() {
views.clear();
pendingEmitters = [];
@@ -136,22 +222,22 @@ export function createVisuals(host) {
clearTracking();
}
- function reset() {
+ function resetInputTracking() {
pendingEmitters = [];
- for (const view of views.values()) view.previousSamples = [];
- fluid.clear();
+ for (const view of views.values()) {
+ view.previousSamples = [];
+ view.previousCentroid = undefined;
+ view.lastFrameTime = undefined;
+ }
}
- function setOptions(next) {
- if (typeof next?.enabled !== 'boolean') return;
-
- const wasEnabled = enabled;
- enabled = next.enabled;
- if (wasEnabled !== enabled) reset();
+ function reset() {
+ resetInputTracking();
+ fluid.clear();
}
// Kept for compatibility with the Three.js-based application shell.
- // Fluid input always comes from point clouds regardless of 3D display toggles.
+ // Debug display visibility is handled exclusively by viewer.js.
function setVisibility() {}
function setRightInset() {}
@@ -160,6 +246,10 @@ export function createVisuals(host) {
return {
clearSetup,
clearTracking,
+ getOptions,
+ getPreset,
+ getPresets,
+ randomSplats,
renderFrame,
renderSetup,
reset,
@@ -174,28 +264,47 @@ function createView(key) {
key,
colors: [],
previousSamples: [],
+ previousCentroid: undefined,
lastFrameTime: undefined
};
}
-function emittersForCloud({ view, points, velocity, now }, sampleCount, bounds) {
+function emitterSources(objects, splatInput) {
+ const filtered = objects.filter((object) => {
+ if (splatInput === 'point-clouds') return object.points?.length;
+ return object.centroid && object.size;
+ });
+
+ return filtered.slice(0, MAX_TOTAL_EMITTERS);
+}
+
+function collectEmitters(objects, splatInput, bounds) {
+ if (splatInput === 'point-clouds') {
+ const perCloudBudget = emitterBudget(objects.length);
+ return objects.flatMap((object) => (
+ pointCloudEmitters(object, perCloudBudget, bounds)
+ ));
+ }
+
+ return objects
+ .map((object) => centroidEmitter(
+ object,
+ bounds,
+ splatInput === 'bounding-box'
+ ? boundingBoxSplatRadius(object.size, bounds)
+ : undefined
+ ))
+ .filter(Boolean);
+}
+
+function pointCloudEmitters({ view, points, velocity, now }, sampleCount, bounds) {
const pointCount = Math.floor(points.length / 3);
const indices = selectEmitterIndices(pointCount, sampleCount);
- const frameDt = view.lastFrameTime === undefined
- ? 1 / 30
- : clamp((now - view.lastFrameTime) / 1000, MIN_FRAME_DT, MAX_FRAME_DT);
- view.lastFrameTime = now;
+ const frameDt = frameDelta(view, now);
- while (view.colors.length < indices.length) view.colors.push(generateColor());
- view.colors.length = indices.length;
-
- const sceneWidth = Math.max(bounds.maxX - bounds.minX, 0.0001);
- const sceneHeight = Math.max(bounds.maxY - bounds.minY, 0.0001);
- const fallbackDelta = {
- x: (Number(velocity[0]) || 0) * frameDt / sceneWidth,
- y: (Number(velocity[1]) || 0) * frameDt / sceneHeight
- };
+ ensureColors(view, indices.length);
+ const fallbackDelta = velocityDelta(velocity, frameDt, bounds);
const nextSamples = [];
const emitters = [];
@@ -209,28 +318,14 @@ function emittersForCloud({ view, points, velocity, now }, sampleCount, bounds)
if (!uv || !insideUnitSquare(uv)) return;
- const previous = view.previousSamples[slot];
- let dx = fallbackDelta.x;
- let dy = fallbackDelta.y;
-
- if (previous) {
- const pointDx = uv.x - previous.x;
- const pointDy = uv.y - previous.y;
- const pointDistance = Math.hypot(pointDx, pointDy);
-
- if (pointDistance <= MAX_POINT_DELTA_UV) {
- dx = pointDx;
- dy = pointDy;
- }
- }
-
- if (Math.hypot(dx, dy) <= MIN_POINT_DELTA_UV) return;
+ const delta = stableDelta(uv, view.previousSamples[slot], fallbackDelta);
+ if (Math.hypot(delta.x, delta.y) <= MIN_POINT_DELTA_UV) return;
emitters.push({
x: uv.x,
y: uv.y,
- dx,
- dy,
+ dx: delta.x,
+ dy: delta.y,
color: view.colors[slot]
});
});
@@ -239,6 +334,65 @@ function emittersForCloud({ view, points, velocity, now }, sampleCount, bounds)
return emitters;
}
+function centroidEmitter({ view, centroid, velocity, now }, bounds, radius) {
+ const uv = projectPointToUv(centroid, bounds);
+ const frameDt = frameDelta(view, now);
+ const fallbackDelta = velocityDelta(velocity, frameDt, bounds);
+
+ if (!uv || !insideUnitSquare(uv)) {
+ view.previousCentroid = uv;
+ return undefined;
+ }
+
+ ensureColors(view, 1);
+ const delta = stableDelta(uv, view.previousCentroid, fallbackDelta);
+ view.previousCentroid = uv;
+
+ if (Math.hypot(delta.x, delta.y) <= MIN_POINT_DELTA_UV) return undefined;
+
+ return {
+ x: uv.x,
+ y: uv.y,
+ dx: delta.x,
+ dy: delta.y,
+ color: view.colors[0],
+ ...(Number.isFinite(radius) ? { radius } : {})
+ };
+}
+
+function frameDelta(view, now) {
+ const dt = view.lastFrameTime === undefined
+ ? 1 / 30
+ : clamp((now - view.lastFrameTime) / 1000, MIN_FRAME_DT, MAX_FRAME_DT);
+ view.lastFrameTime = now;
+ return dt;
+}
+
+function velocityDelta(velocity, dt, bounds) {
+ const width = Math.max(bounds.maxX - bounds.minX, 0.0001);
+ const height = Math.max(bounds.maxY - bounds.minY, 0.0001);
+
+ return {
+ x: (Number(velocity?.[0]) || 0) * dt / width,
+ y: (Number(velocity?.[1]) || 0) * dt / height
+ };
+}
+
+function stableDelta(current, previous, fallback) {
+ if (!previous) return fallback;
+
+ const dx = current.x - previous.x;
+ const dy = current.y - previous.y;
+ if (Math.hypot(dx, dy) <= MAX_POINT_DELTA_UV) return { x: dx, y: dy };
+
+ return fallback;
+}
+
+function ensureColors(view, count) {
+ while (view.colors.length < count) view.colors.push(generateColor());
+ view.colors.length = count;
+}
+
export function emitterBudget(cloudCount) {
const count = Math.max(1, Number(cloudCount) || 1);
return Math.max(
@@ -276,6 +430,77 @@ export function projectPointToUv(point, bounds) {
};
}
+export function boundingBoxSplatRadius(size, bounds) {
+ if (!size || size.length < 2) return undefined;
+
+ const width = Math.max(bounds.maxX - bounds.minX, 0.0001);
+ const height = Math.max(bounds.maxY - bounds.minY, 0.0001);
+ const widthUv = Math.abs(Number(size[0]) || 0) / width;
+ const heightUv = Math.abs(Number(size[1]) || 0) / height;
+
+ // Pavel's splat radius is the Gaussian squared-distance denominator * 100.
+ // Use an area-equivalent projected radius so a tall human box does not
+ // explode into a full-screen splat while still scaling with box footprint.
+ const radiusUv = 0.5 * Math.sqrt(widthUv * heightUv);
+ return clamp(
+ radiusUv * radiusUv * 100,
+ BOUNDING_BOX_RADIUS_MIN,
+ BOUNDING_BOX_RADIUS_MAX
+ );
+}
+
+function effectiveDefaults(engineConfig = {}) {
+ return {
+ ...REFERENCE_DEFAULTS,
+ dyeResolution: finiteOr(engineConfig.DYE_RESOLUTION, REFERENCE_DEFAULTS.dyeResolution),
+ simResolution: finiteOr(engineConfig.SIM_RESOLUTION, REFERENCE_DEFAULTS.simResolution),
+ densityDissipation: finiteOr(
+ engineConfig.DENSITY_DISSIPATION,
+ REFERENCE_DEFAULTS.densityDissipation
+ ),
+ velocityDissipation: finiteOr(
+ engineConfig.VELOCITY_DISSIPATION,
+ REFERENCE_DEFAULTS.velocityDissipation
+ ),
+ pressure: finiteOr(engineConfig.PRESSURE, REFERENCE_DEFAULTS.pressure),
+ curl: finiteOr(engineConfig.CURL, REFERENCE_DEFAULTS.curl),
+ splatRadius: finiteOr(engineConfig.SPLAT_RADIUS, REFERENCE_DEFAULTS.splatRadius),
+ shading: booleanOr(engineConfig.SHADING, REFERENCE_DEFAULTS.shading),
+ colorful: booleanOr(engineConfig.COLORFUL, REFERENCE_DEFAULTS.colorful),
+ paused: booleanOr(engineConfig.PAUSED, REFERENCE_DEFAULTS.paused),
+ bloom: booleanOr(engineConfig.BLOOM, REFERENCE_DEFAULTS.bloom),
+ bloomIntensity: finiteOr(
+ engineConfig.BLOOM_INTENSITY,
+ REFERENCE_DEFAULTS.bloomIntensity
+ ),
+ bloomThreshold: finiteOr(
+ engineConfig.BLOOM_THRESHOLD,
+ REFERENCE_DEFAULTS.bloomThreshold
+ ),
+ sunrays: booleanOr(engineConfig.SUNRAYS, REFERENCE_DEFAULTS.sunrays),
+ sunraysWeight: finiteOr(
+ engineConfig.SUNRAYS_WEIGHT,
+ REFERENCE_DEFAULTS.sunraysWeight
+ )
+ };
+}
+
+function pickVisualOptions(value) {
+ const result = {};
+ for (const key of Object.keys(REFERENCE_DEFAULTS)) {
+ if (key in value) result[key] = value[key];
+ }
+ return result;
+}
+
+function fluidConfigFromOptions(value) {
+ const result = {};
+ for (const [optionKey, configKey] of Object.entries(FLUID_CONFIG_KEYS)) {
+ if (optionKey in value) result[configKey] = value[optionKey];
+ }
+ return result;
+}
+
function sceneBounds(scenes, selectedSceneAddress) {
const selected = selectedSceneAddress
? scenes.find((candidate) => candidate.getAddress?.() === selectedSceneAddress)
@@ -343,6 +568,9 @@ function safeCreateFluid(canvas) {
return {
supported: false,
clear() {},
+ getConfig() { return {}; },
+ randomSplats() {},
+ setConfig() {},
update() {}
};
}
@@ -359,12 +587,22 @@ function walk(container, visit) {
}
function vector3(value, fallback) {
- if (!value || value.length < 3) return [...fallback];
+ if (!value || value.length < 3) return fallback ? [...fallback] : undefined;
return [0, 1, 2].map((index) => (
- Number.isFinite(Number(value[index])) ? Number(value[index]) : fallback[index]
+ Number.isFinite(Number(value[index]))
+ ? Number(value[index])
+ : fallback?.[index]
));
}
+function finiteOr(value, fallback) {
+ return Number.isFinite(Number(value)) ? Number(value) : fallback;
+}
+
+function booleanOr(value, fallback) {
+ return typeof value === 'boolean' ? value : fallback;
+}
+
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
diff --git a/tests/fluid-visuals.test.mjs b/tests/fluid-visuals.test.mjs
index c53b482..29f963d 100644
--- a/tests/fluid-visuals.test.mjs
+++ b/tests/fluid-visuals.test.mjs
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
+ boundingBoxSplatRadius,
emitterBudget,
projectPointToUv,
selectEmitterIndices
@@ -13,8 +14,7 @@ test('emitter budget stays bounded and shares capacity across clouds', () => {
assert.equal(emitterBudget(2), 8);
assert.equal(emitterBudget(3), 5);
assert.equal(emitterBudget(6), 2);
- assert.equal(emitterBudget(24), 1);
- assert.equal(emitterBudget(100), 1);
+ assert.equal(emitterBudget(16), 1);
});
test('point-cloud subsampling is deterministic and evenly distributed', () => {
@@ -22,9 +22,9 @@ test('point-cloud subsampling is deterministic and evenly distributed', () => {
assert.deepEqual(selectEmitterIndices(4, 12), [0, 1, 2, 3]);
assert.deepEqual(selectEmitterIndices(0, 4), []);
- const indices = selectEmitterIndices(1000, 12);
- assert.equal(indices.length, 12);
- assert.equal(new Set(indices).size, 12);
+ const indices = selectEmitterIndices(1000, 8);
+ assert.equal(indices.length, 8);
+ assert.equal(new Set(indices).size, 8);
assert.ok(indices.every((index) => index >= 0 && index < 1000));
});
@@ -36,7 +36,15 @@ test('point positions map to fluid UV coordinates using scene bounds', () => {
assert.equal(projectPointToUv([Number.NaN, 0, 0], bounds), undefined);
});
-test('fluid engine preserves PavelDoGreat default visual parameters and removes pointer input', () => {
+test('bounding-box splat radius follows projected box footprint', () => {
+ const bounds = { minX: -5, maxX: 5, minY: 0, maxY: 4 };
+
+ assert.ok(Math.abs(boundingBoxSplatRadius([0.5, 1.6, 0.5], bounds) - 0.5) < 1e-9);
+ assert.equal(boundingBoxSplatRadius([0.01, 0.01, 0.01], bounds), 0.01);
+ assert.equal(boundingBoxSplatRadius([10, 4, 1], bounds), 1);
+});
+
+test('fluid engine preserves Pavel defaults and exposes the GUI control API', () => {
const source = readFileSync(new URL('../src/fluid.js', import.meta.url), 'utf8');
for (const expected of [
@@ -68,4 +76,51 @@ test('fluid engine preserves PavelDoGreat default visual parameters and removes
assert.ok(!source.includes("canvas.addEventListener('mousedown'"));
assert.ok(!source.includes("canvas.addEventListener('touchstart'"));
assert.ok(source.includes('function applyEmitters (emitters)'));
+ assert.ok(source.includes('function setConfig (next = {})'));
+ assert.ok(source.includes('function randomSplats (amount ='));
+ assert.ok(source.includes('splat(x, y, dx, dy, color, emitter.radius)'));
+});
+
+test('Visuals exposes Pavel controls, presets, and Augmenta splat inputs', () => {
+ const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
+
+ for (const id of [
+ 'visual-preset',
+ 'visual-dye-resolution',
+ 'visual-sim-resolution',
+ 'visual-density-dissipation',
+ 'visual-velocity-dissipation',
+ 'visual-pressure',
+ 'visual-curl',
+ 'visual-splat-radius',
+ 'visual-shading',
+ 'visual-colorful',
+ 'visual-paused',
+ 'visual-random-splats',
+ 'visual-bloom',
+ 'visual-bloom-intensity',
+ 'visual-bloom-threshold',
+ 'visual-sunrays',
+ 'visual-sunrays-weight',
+ 'visual-splat-input'
+ ]) {
+ assert.ok(html.includes(`id="${id}"`), `missing visual control: ${id}`);
+ }
+
+ assert.ok(html.includes('
Centroid '));
+ assert.ok(html.includes('
Point clouds '));
+ assert.ok(html.includes('
Bounding box '));
+ assert.ok(!html.includes('id="display-section"'));
+ assert.ok(html.includes('
Augmenta data '));
+});
+
+test('Three.js debug renderer is transparent so selected data draws above fluid', () => {
+ const viewer = readFileSync(new URL('../src/viewer.js', import.meta.url), 'utf8');
+ const styles = readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8');
+
+ assert.ok(viewer.includes('new THREE.WebGLRenderer({ antialias: true, alpha: true })'));
+ assert.ok(viewer.includes("renderer.domElement.classList.add('debug-viewer-canvas')"));
+ assert.ok(viewer.includes('renderer.setClearColor(0x000000, 0)'));
+ assert.ok(viewer.includes('sceneHelperGroup.visible = scene'));
+ assert.ok(styles.includes('#canvas-host .debug-viewer-canvas'));
});