Skip to content
Merged
7 changes: 7 additions & 0 deletions src/components/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@
import { measureMode, measureClick, measureHoverAt } from '$lib/measure';
import { pinsGroup, openAnnotation, showNotePins } from '$lib/annotationsHandler';
import { setParticleRoot } from '$lib/particleRuntime';
import { setBackgroundRoot, tickBackgroundLayer } from '$lib/backgroundLayer'; // 65 M2
import { tickFluidLayer } from '$lib/fluidLayer'; // 65 M6: smoke / ink (a look entry; the XR panel)
import WaterLayer from './water/WaterLayer.svelte';
import { registerEditResumeSources } from '$lib/editResume';
import { sendPing } from '$lib/ping';
Expand Down Expand Up @@ -426,6 +428,8 @@
tickAnimatedMixers(); // imported clips run on the synced clock
tickPackBehaviors(); // 33 P2: doors/lids/levers pose from their shared state (rest in Edit)
tickSim($objectsGroup, camera.current, renderer, performance.now()); // 36-sim (after mixers: jiggle rides the posed bones)
tickBackgroundLayer(camera.current, renderer, performance.now()); // 65 M2: the shader background (inert without one)
tickFluidLayer(camera.current, renderer, performance.now()); // 65 M6: steps the XR smoke panel (the screen effect steps itself)
tickAnimationPreview(); // Animation window: local transform preview (not synced)
tickSequences(); // 48 S2: shots / clips / audio / events of running sequences
tickMeshEdit(); // vertex handles follow the object if it moves (119)
Expand Down Expand Up @@ -1830,6 +1834,9 @@
sceneObjects — they'd leak into GLTF sync). oncreate passes the ref
DIRECTLY (the { ref } destructure trap, N1). -->
<T.Group name="particle-root" oncreate={(ref: any) => setParticleRoot(ref)} />
<!-- 65 M2: the shader BACKGROUND layer's meshes (screen / VR sphere / plane overlay) — scene
root, never in sceneObjects, so nothing under it is saved or sent -->
<T.Group name="background-root" oncreate={(ref: any) => setBackgroundRoot(ref)} />
<!-- 36-water: water surfaces/bodies/bubbles (scene root, local) + the render hooks -->
<WaterLayer />

Expand Down
71 changes: 58 additions & 13 deletions src/components/editors/ShaderEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@
shaderRefusalReason
} from '$lib/shaderGraph';
import { beginShaderGesture, endShaderGesture } from '$lib/shaderSync';
import { shaderNodeDefs, shaderNodeDef, SURFACE_NODE, POST_OUTPUT_NODE } from '$lib/shaderCatalog';
import { shaderNodeDefs, shaderNodeDef, SURFACE_NODE, POST_OUTPUT_NODE, BACKGROUND_OUTPUT_NODE, stageForDomain } from '$lib/shaderCatalog';
// 65 M2: the BACKGROUND domain — one document per scene, so its scope is fixed
import { BACKGROUND_KEY, removeBackground } from '$lib/backgroundLayer';
import { backgroundPresets, loadBackgroundPresets, applyBackgroundPreset } from '$lib/backgroundPresets';
import { BUILTIN_PRESET_KEY } from '$lib/backgroundPresetsCore';
// P4: the POST domain. The editor is one surface for two domains — same cards, same
// palette, same pane menu — because a post graph IS a shader graph; what differs is
// which nodes mean anything, which terminal node the graph ends at, and (since a post
Expand Down Expand Up @@ -100,7 +104,8 @@
(def) =>
def.key !== SURFACE_NODE &&
def.key !== POST_OUTPUT_NODE &&
(!def.stages || def.stages.includes(which === 'post' ? 'post' : 'fragment'))
def.key !== BACKGROUND_OUTPUT_NODE &&
(!def.stages || def.stages.includes(/** @type {any} */ (stageForDomain(which))))
);
const catalog = $derived(catalogFor($shaderDomain));
const groups = $derived([...new Set(catalog.map((def) => def.group))]);
Expand All @@ -111,6 +116,10 @@
// ---- scope: selection-driven in SURFACE, picked in POST ---------------------
const selectedUuid = $derived($selectedObjects?.length === 1 ? $selectedObjects[0] : null);
const isPost = $derived($shaderDomain === 'post');
const isBg = $derived($shaderDomain === 'background');
$effect(() => {
if (isBg) void loadBackgroundPresets();
});
/** every post graph, re-derived off the store so a new one appears at once */
/** @param {any} _poke */
const graphsOf = (_poke) => postGraphKeys();
Expand All @@ -121,17 +130,21 @@
? $activePostGraph
: (postGraphs[0]?.key ?? '')
);
const scope = $derived(isPost ? postScope : (selectedUuid ?? SCENE_GRAPH_KEY));
const scope = $derived(isBg ? BACKGROUND_KEY : isPost ? postScope : (selectedUuid ?? SCENE_GRAPH_KEY));
/** the navigator's list, split by domain (see its markup below) */
const treeDocuments = $derived(
Object.fromEntries(
Object.entries($shaderGraphs).filter(([key]) => key.startsWith('post:') === isPost)
Object.entries($shaderGraphs).filter(([key]) =>
isBg ? key === BACKGROUND_KEY : key !== BACKGROUND_KEY && key.startsWith('post:') === isPost
)
)
);
const doc = $derived(scope ? ($shaderGraphs[scope] ?? null) : null);
const errors = $derived(scope ? ($shaderErrors[scope] ?? []) : []);
const ownerName = $derived(
isPost
isBg
? 'The background'
: isPost
? scope
? postGraphName(scope)
: 'No post effect'
Expand All @@ -140,7 +153,9 @@
: $objectsGroup?.getObjectByProperty('uuid', scope)?.name || 'This object'
);
const scopeLabel = $derived(
isPost
isBg
? 'Scene background'
: isPost
? ownerName + ' — post effect'
: scope === SCENE_GRAPH_KEY
? 'Scene default material'
Expand Down Expand Up @@ -280,6 +295,10 @@

// ---- actions ---------------------------------------------------------------
function createGraph() {
if (isBg) {
void applyBackgroundPreset(BUILTIN_PRESET_KEY);
return;
}
if (isPost) {
// in the post half "create" MINTS a document (there is no object to attach one
// to), and the new one becomes the scope so you are looking at what you made
Expand Down Expand Up @@ -311,6 +330,10 @@
}

function removeGraph() {
if (isBg) {
removeBackground();
return;
}
if (isPost) {
if (scope) deletePostGraph(scope);
activePostGraph.set(null);
Expand Down Expand Up @@ -425,7 +448,7 @@
{ label: 'Disconnect all', action: () => disconnectNode(id) },
// the Surface output is the graph's terminal — there is exactly one, and a
// graph without it cannot compile at all
...(node.type === SURFACE_NODE
...(node.type === SURFACE_NODE || node.type === BACKGROUND_OUTPUT_NODE
? []
: [{ label: 'Delete node', danger: true, action: () => deleteNode(id) }])
]
Expand Down Expand Up @@ -653,7 +676,7 @@
<button
class="tp-seg-btn"
id="shader-domain-surface"
aria-pressed={!isPost}
aria-pressed={!isPost && !isBg}
title="Materials: this object's, or the scene's default"
onclick={() => shaderDomain.set('surface')}>Surface</button
>
Expand All @@ -664,6 +687,13 @@
title="Post effects: a fragment over the finished frame, added to the scene look"
onclick={() => shaderDomain.set('post')}>Post</button
>
<button
class="tp-seg-btn"
id="shader-domain-background"
aria-pressed={isBg}
title="Background: a moving shader behind the scene (Configure Scene ▸ Background)"
onclick={() => shaderDomain.set('background')}>Background</button
>
</div>
{#if isPost && postGraphs.length}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
Expand Down Expand Up @@ -712,8 +742,8 @@
<GraphTree
kind="shader"
documents={treeDocuments}
sceneKey={SCENE_GRAPH_KEY}
label={isPost ? 'Post effects' : 'Shaders'}
sceneKey={isBg ? BACKGROUND_KEY : SCENE_GRAPH_KEY}
label={isBg ? 'Background' : isPost ? 'Post effects' : 'Shaders'}
paneHeight={paneH}
/>
<div class="shader-side-scroll" use:minimalScroll>
Expand Down Expand Up @@ -781,16 +811,29 @@
<!-- the `#flow-empty-state` shape: ONE centred call to action -->
<div id="shader-empty-state" class="shader-empty">
<p class="text-sm text-text-2">
{#if isPost}
{#if isBg}
The scene has no background yet
{:else if isPost}
No post effect to edit yet
{:else}
<span class="font-semibold text-text">{ownerName}</span> has no shader yet
{/if}
</p>
<button id="shader-create-btn" class="shader-create" onclick={createGraph}>
{isPost ? 'Create post effect' : 'Create shader'}
{isBg ? 'Create background' : isPost ? 'Create post effect' : 'Create shader'}
</button>
{#if isPost}
{#if isBg}
<div class="shader-preset-row" id="shader-bg-presets">
{#each $backgroundPresets.presets as preset (preset.key)}
<button class="shader-preset" title={preset.hint} onclick={() => applyBackgroundPreset(preset.key)}
>{preset.label}</button
>
{/each}
</div>
<p class="text-[11px] text-text-faint">
A background draws behind the scene, or on a plane — Configure Scene ▸ Background
</p>
{:else if isPost}
<!-- the presets are the fastest way to see what the domain can do, and each
one is an ordinary graph you can then take apart -->
<div class="shader-preset-row">
Expand Down Expand Up @@ -1002,6 +1045,8 @@
{doc.nodes.length} nodes · {doc.edges.length} wires · {doc.backend}
</p>
<p class="shader-hint">Replicates to peers · saved with the scene</p>
{:else if isBg}
<p class="shader-hint">Pick a preset to start a background, then take it apart here.</p>
{:else if isPost}
<p class="shader-hint">
Create a post effect, then add it to a look in Configure Scene ▸
Expand Down
12 changes: 11 additions & 1 deletion src/components/fx/ParticleMotionRows.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
];
</script>

<div class="pm" data-keywords="particles render stretch trail ribbon streak sparks velocity">
<div class="pm" data-keywords="particles render stretch trail ribbon streak sparks velocity flow field curl noise swirl">
<!-- four options do not fit the PropRow middle column (measured: "SpritesStretch" ran
together and "Ribbon" clipped), so the control takes the whole row under its label -->
<div class="pm-mode">
Expand All @@ -45,6 +45,16 @@
In Local space the ribbon rides the object. Set Space to World for a trail behind it.
</p>
{/if}
<!-- 65 M6: the curl-noise flow field (0 = off). Divergence-free, so particles swirl like
smoke or ink without clumping; the same on every peer (a function of the synced clock) -->
<PropRow label="Flow field" slider min={0} max={3} step={0.05} id="particles-flow" value={p.flow ?? 0}
title="Swirls the particles through a smooth curl-noise field (0 = off)" onchange={(v) => set({ flow: v })} />
{#if (p.flow ?? 0) > 0}
<PropRow label="Swirl size" slider min={0.1} max={3} step={0.05} id="particles-flow-scale" value={p.flowScale ?? 0.6}
title="The field's frequency: higher makes tighter swirls" onchange={(v) => set({ flowScale: v })} />
<PropRow label="Field speed" slider min={0} max={2} step={0.05} id="particles-flow-speed" value={p.flowSpeed ?? 0.3}
title="How fast the field itself changes (0 = a fixed field)" onchange={(v) => set({ flowSpeed: v })} />
{/if}
{#if world}
<PropRow label="Inherit velocity" slider min={0} max={1} step={0.05} value={p.inherit ?? 0}
title="How much of the emitter's own speed a particle keeps when it is born" onchange={(v) => set({ inherit: v })} />
Expand Down
2 changes: 2 additions & 0 deletions src/components/menu/Inspector.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import MenuButton from './inspector/MenuButton.svelte';
import InsToggle from './inspector/InsToggle.svelte';
import SceneQualitySection from './inspector/SceneQualitySection.svelte'; // 42 I12
import BackgroundLayerSection from './inspector/BackgroundLayerSection.svelte'; // 65 M2
import ArrangeSection from './inspector/ArrangeSection.svelte'; // 47 M2
import TerrainPaintSection from './inspector/TerrainPaintSection.svelte'; // 47 T1/T2
import MeasureSection from './inspector/MeasureSection.svelte'; // 48 B2
Expand Down Expand Up @@ -2122,6 +2123,7 @@
Open the shader editor
</button>
</Section>
<BackgroundLayerSection />

<!-- 16-P4: everything about the VIEWPORT camera in one place (it used to be a
"Camera lens" sub-label buried in View): lens, clip planes, orbit feel,
Expand Down
127 changes: 127 additions & 0 deletions src/components/menu/inspector/BackgroundLayerSection.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<script>
// 65 M2 — Configure Scene ▸ Background: the fluid shader background layer. Scene data like
// the look above it (the document is `shaderGraphs['background']`, so it replicates, saves
// and undoes with the scene); what stays local is the pointer that moves it and View ▸
// Overrides ▸ Scene shaders, which switches it off on this screen only.
import Section from '../../ui/Section.svelte';
import PropRow from '../../ui/PropRow.svelte';
import Segmented from '../../ui/Segmented.svelte';
import ThemedSelect from '../../ui/ThemedSelect.svelte';
import InsToggle from './InsToggle.svelte';
import { onMount } from 'svelte';
import { shaderGraphs, shaderErrors, openShaderEditor } from '$lib/shaderGraph';
import { shaderDomain } from '$lib/postGraphs';
import { selectedObjects, objectsGroup } from '../../../stores/sceneStore';
import { backgroundPresets, loadBackgroundPresets, applyBackgroundPreset } from '$lib/backgroundPresets';
import { BACKGROUND_KEY, backgroundState, setBackgroundLayer, removeBackground } from '$lib/backgroundLayer';
import { normalizeLayer } from '$lib/backgroundLayerCore';

onMount(() => {
void loadBackgroundPresets();
});

const MODES = [
{ value: 'screen', label: 'Behind scene', title: 'Full screen behind everything (a big sphere around you in VR)' },
{ value: 'plane', label: 'On a plane', title: 'Drawn on the surface of a plane you pick in the scene' },
{ value: 'off', label: 'Off', title: 'Kept in the scene but not drawn' }
];

const doc = $derived(/** @type {any} */ ($shaderGraphs[BACKGROUND_KEY] ?? null));
const layer = $derived(normalizeLayer(doc?.layer));
const errors = $derived($shaderErrors[BACKGROUND_KEY] ?? []);
const items = $derived($backgroundPresets.presets.map((p) => ({ value: p.key, name: p.label })));
const hint = $derived($backgroundPresets.presets.find((p) => p.key === doc?.preset)?.hint ?? '');
/** @param {any} _poke */
const targetName = (_poke) =>
layer.target ? $objectsGroup?.getObjectByProperty?.('uuid', layer.target)?.name || 'a missing object' : '';
const target = $derived(targetName($objectsGroup));
const pickable = $derived($selectedObjects?.length === 1);
let busy = $state(false);
let failure = $state('');

/** @param {string} key */
async function pick(key) {
busy = true;
failure = '';
const result = await applyBackgroundPreset(key);
busy = false;
if (!result.ok) failure = result.error ?? 'That preset could not be applied.';
}

function edit() {
shaderDomain.set('background');
void openShaderEditor();
}
</script>

<Section variant="panel" label="Background" badge="Shared" aliases={['Shader background', 'Fluid background']}>
<p class="mb-1 text-[length:var(--fs-badge)] text-text-muted">
A moving shader drawn behind the scene or on a plane — everyone sees it, and it drifts in
step for everyone. Your own pointer moves your own view of it.
</p>
<PropRow label="Preset" valueBox={false} id="background-preset-row">
{#snippet control()}
<ThemedSelect
id="background-preset"
{items}
value={doc?.preset ?? undefined}
placeholder={doc ? 'Custom graph' : 'Choose a background'}
disabled={busy}
onchange={(v) => pick(v)}
/>
{/snippet}
</PropRow>
{#if hint}
<p id="background-preset-hint" class="mb-1 text-[length:var(--fs-badge)] italic text-text-muted">{hint}</p>
{/if}
{#if $backgroundPresets.status === 'offline'}
<p id="background-presets-offline" class="mb-1 text-[length:var(--fs-badge)] text-text-muted">
{$backgroundPresets.error} The built-in preset still works.
<button class="underline" onclick={() => loadBackgroundPresets({ force: true })}>Retry</button>
</p>
{/if}
{#if failure}
<p id="background-failure" class="mb-1 text-[length:var(--fs-badge)] text-warn-text" role="alert">{failure}</p>
{/if}
{#if doc}
<PropRow label="Where" valueBox={false} id="background-mode-row">
{#snippet control()}
<Segmented id="background-mode" label="Where the background draws" full options={MODES} value={layer.mode} onchange={(v) => setBackgroundLayer({ mode: /** @type {any} */ (v) })} />
{/snippet}
</PropRow>
{#if layer.mode === 'plane'}
<p id="background-target" class="mb-1 text-[length:var(--fs-badge)] text-text-muted">
{target ? 'Drawn on ' + target + '.' : 'Pick a plane: select it, then press Use selected.'}
</p>
<button
id="background-use-selected"
class="ui-chip mb-1 w-full justify-center"
disabled={!pickable}
title={pickable ? 'Draw the background on the selected object' : 'Select one object first'}
onclick={() => setBackgroundLayer({ target: $selectedObjects[0] })}
>
Use selected
</button>
{/if}
<InsToggle id="background-pointer" checked={layer.pointer} onchange={(e) => setBackgroundLayer({ pointer: e.currentTarget.checked })}>
Follow the pointer (or the right controller in VR)
</InsToggle>
<InsToggle id="background-scroll" checked={layer.scroll} onchange={(e) => setBackgroundLayer({ scroll: e.currentTarget.checked })}>
React to scrolling
</InsToggle>
{#if errors.length}
<p id="background-errors" class="mb-1 text-[length:var(--fs-badge)] text-warn-text" role="alert">{errors[0]}</p>
{/if}
<p id="background-status" class="mb-1 text-[length:var(--fs-badge)] text-text-muted">
{$backgroundState.draw === 'none'
? 'Not drawn on this screen.'
: $backgroundState.tier === 'low'
? 'Drawn at the lighter quality this device can afford.'
: 'Drawn at full quality.'}
</p>
<div class="flex gap-1">
<button id="background-edit" class="ui-chip flex-1 justify-center" onclick={edit} title="Open the background graph in the shader editor">Edit graph</button>
<button id="background-remove" class="ui-chip flex-1 justify-center" onclick={() => removeBackground()} title="Remove the background (undo brings it back)">Remove</button>
</div>
{/if}
</Section>
Loading
Loading