babylon.quarks-editor 0.17.9 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/galleryPlayback.d.ts +6 -1
- package/dist/core/galleryPlayback.js +30 -1
- package/dist/core/galleryScene.d.ts +8 -0
- package/dist/core/galleryScene.js +24 -0
- package/dist/core/renderDebug.d.ts +8 -0
- package/dist/core/renderDebug.js +42 -0
- package/dist/core/scrub.d.ts +10 -1
- package/dist/core/scrub.js +41 -1
- package/dist/core/timeline.d.ts +2 -1
- package/dist/core/timeline.js +26 -2
- package/dist/react/EffectEditorHost.js +87 -11
- package/dist/react/GalleryPanel.d.ts +1 -0
- package/dist/react/GalleryPanel.js +45 -14
- package/dist/react/TimelinePanel.d.ts +2 -0
- package/dist/react/TimelinePanel.js +30 -13
- package/package.json +1 -1
- package/src/core/galleryPlayback.ts +36 -1
- package/src/core/galleryScene.ts +34 -0
- package/src/core/renderDebug.ts +46 -0
- package/src/core/scrub.ts +44 -1
- package/src/core/timeline.ts +30 -2
- package/src/react/EffectEditorHost.tsx +107 -9
- package/src/react/GalleryPanel.tsx +78 -14
- package/src/react/TimelinePanel.tsx +30 -13
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
4
|
-
import { CheckCircleIcon, ChevronDownIcon, ChevronRightIcon, FolderIcon, FolderOpenIcon, SparklesIcon, } from '@heroicons/react/24/solid';
|
|
4
|
+
import { CheckCircleIcon, ChevronDownIcon, ChevronRightIcon, FolderIcon, FolderOpenIcon, SparklesIcon, XMarkIcon, } from '@heroicons/react/24/solid';
|
|
5
5
|
import { setGalleryDragSession } from '../core/galleryDragSession';
|
|
6
6
|
import { buildGalleryTree, filterGalleryTree, flattenVisibleGalleryRows, writeGalleryDrag, } from '../core/galleryTree';
|
|
7
7
|
import { iconStyle } from './icons';
|
|
@@ -15,17 +15,27 @@ function endGalleryDrag() {
|
|
|
15
15
|
setGalleryDragSession(null);
|
|
16
16
|
}
|
|
17
17
|
function EffectRow(props) {
|
|
18
|
-
const { entry, depth, focused, inPreview, onFocus, onTogglePreview } = props;
|
|
18
|
+
const { entry, depth, focused, inPreview, onFocus, onTogglePreview, onRemoveFromScene } = props;
|
|
19
19
|
const isLooping = entry.systems.some((system) => system.looping);
|
|
20
|
+
const parkedOnStage = entry.inScene && !inPreview;
|
|
21
|
+
const rowBackground = focused
|
|
22
|
+
? 'rgba(120, 165, 255, 0.22)'
|
|
23
|
+
: inPreview
|
|
24
|
+
? 'rgba(120, 165, 255, 0.1)'
|
|
25
|
+
: parkedOnStage
|
|
26
|
+
? 'rgba(120, 165, 255, 0.04)'
|
|
27
|
+
: 'transparent';
|
|
20
28
|
return (_jsxs("div", { style: {
|
|
21
29
|
display: 'flex',
|
|
22
30
|
alignItems: 'stretch',
|
|
23
31
|
width: '100%',
|
|
24
32
|
height: GALLERY_ROW_HEIGHT,
|
|
25
33
|
borderBottom: `1px solid ${theme.border}`,
|
|
26
|
-
background:
|
|
34
|
+
background: rowBackground,
|
|
27
35
|
boxSizing: 'border-box',
|
|
28
|
-
}, children: [entry.inScene ? (_jsx("button", { type: "button", className: "qe-hover", title: inPreview
|
|
36
|
+
}, children: [entry.inScene ? (_jsx("button", { type: "button", className: "qe-hover", title: inPreview
|
|
37
|
+
? 'Remove from stage preview — stops group playback and clears particles; effect stays on stage'
|
|
38
|
+
: 'Add to stage preview — plays with Stage Play/Pause', style: {
|
|
29
39
|
flexShrink: 0,
|
|
30
40
|
width: 28,
|
|
31
41
|
border: 'none',
|
|
@@ -40,9 +50,9 @@ function EffectRow(props) {
|
|
|
40
50
|
}, onClick: (e) => {
|
|
41
51
|
e.stopPropagation();
|
|
42
52
|
onTogglePreview(entry);
|
|
43
|
-
}, children: inPreview ? _jsx(CheckCircleIcon, { style: iconStyle(14) }) : _jsx("span", { style: { width: 10, height: 10, border: `1px solid currentColor`, borderRadius: 2, boxSizing: 'border-box' } }) })) : (_jsx("span", { style: { width: 28, flexShrink: 0, borderRight: `1px solid ${theme.border}` } })), _jsxs("button", { type: "button", draggable: true, className: "qe-hover-bg", title: entry.inScene
|
|
44
|
-
? `${entry.path} —
|
|
45
|
-
: `${entry.path} — drag into viewport`, style: {
|
|
53
|
+
}, children: inPreview ? (_jsx(CheckCircleIcon, { style: iconStyle(14) })) : (_jsx("span", { style: { width: 10, height: 10, border: `1px solid currentColor`, borderRadius: 2, boxSizing: 'border-box' } })) })) : (_jsx("span", { style: { width: 28, flexShrink: 0, borderRight: `1px solid ${theme.border}` } })), _jsxs("button", { type: "button", draggable: true, className: "qe-hover-bg", title: entry.inScene
|
|
54
|
+
? `${entry.path} — on stage${inPreview ? ' · previewing' : ' · parked'} · Ctrl+click toggles preview`
|
|
55
|
+
: `${entry.path} — catalog only · drag into viewport to place`, style: {
|
|
46
56
|
display: 'flex',
|
|
47
57
|
alignItems: 'center',
|
|
48
58
|
gap: 8,
|
|
@@ -53,7 +63,7 @@ function EffectRow(props) {
|
|
|
53
63
|
border: 'none',
|
|
54
64
|
background: 'transparent',
|
|
55
65
|
color: focused ? theme.text : theme.textDim,
|
|
56
|
-
padding: `0
|
|
66
|
+
padding: `0 8px 0 ${8 + depth * 14}px`,
|
|
57
67
|
fontSize: 12.5,
|
|
58
68
|
cursor: 'grab',
|
|
59
69
|
fontFamily: theme.font,
|
|
@@ -63,7 +73,28 @@ function EffectRow(props) {
|
|
|
63
73
|
return;
|
|
64
74
|
}
|
|
65
75
|
onFocus(entry);
|
|
66
|
-
}, onDragStart: (e) => beginGalleryDrag(e, { kind: 'effect', id: entry.root.uniqueId }), onDragEnd: endGalleryDrag, children: [_jsx("span", { style: {
|
|
76
|
+
}, onDragStart: (e) => beginGalleryDrag(e, { kind: 'effect', id: entry.root.uniqueId }), onDragEnd: endGalleryDrag, children: [_jsx("span", { style: {
|
|
77
|
+
width: 14,
|
|
78
|
+
flexShrink: 0,
|
|
79
|
+
opacity: entry.inScene ? 1 : 0.35,
|
|
80
|
+
color: inPreview ? theme.accent : entry.inScene ? theme.text : theme.textDim,
|
|
81
|
+
display: 'inline-flex',
|
|
82
|
+
}, title: entry.inScene ? 'On stage' : 'Catalog only', children: _jsx(SparklesIcon, { style: iconStyle(14) }) }), _jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: entry.name }), parkedOnStage && (_jsx("span", { title: "On stage but not in preview", style: { flexShrink: 0, fontSize: 10, color: theme.textDim, letterSpacing: 0.3 }, children: "parked" })), isLooping && (_jsx("span", { title: "Looping effect", style: { flexShrink: 0, fontSize: 11, color: theme.textDim }, children: "\u27F3" }))] }), entry.inScene ? (_jsx("button", { type: "button", className: "qe-hover", title: "Remove from scene \u2014 returns to catalog; drag into viewport to place again", style: {
|
|
83
|
+
flexShrink: 0,
|
|
84
|
+
width: 28,
|
|
85
|
+
border: 'none',
|
|
86
|
+
borderLeft: `1px solid ${theme.border}`,
|
|
87
|
+
background: 'transparent',
|
|
88
|
+
color: theme.textDim,
|
|
89
|
+
cursor: 'pointer',
|
|
90
|
+
display: 'inline-flex',
|
|
91
|
+
alignItems: 'center',
|
|
92
|
+
justifyContent: 'center',
|
|
93
|
+
padding: 0,
|
|
94
|
+
}, onClick: (e) => {
|
|
95
|
+
e.stopPropagation();
|
|
96
|
+
onRemoveFromScene(entry);
|
|
97
|
+
}, children: _jsx(XMarkIcon, { style: iconStyle(13) }) })) : null] }));
|
|
67
98
|
}
|
|
68
99
|
function FolderRow(props) {
|
|
69
100
|
const { node, depth, effectCount, open, onToggle } = props;
|
|
@@ -98,7 +129,7 @@ function flatRowKey(row) {
|
|
|
98
129
|
return row.kind === 'folder' ? `folder:${row.node.path}` : `effect:${row.node.entry.root.uniqueId}`;
|
|
99
130
|
}
|
|
100
131
|
function VirtualGalleryTree(props) {
|
|
101
|
-
const { rows, closedFolders, focusedRoot, previewRootIds, onFocus, onTogglePreview, onToggleFolder } = props;
|
|
132
|
+
const { rows, closedFolders, focusedRoot, previewRootIds, onFocus, onTogglePreview, onRemoveFromScene, onToggleFolder } = props;
|
|
102
133
|
const scrollRef = useRef(null);
|
|
103
134
|
const virtualizer = useVirtualizer({
|
|
104
135
|
count: rows.length,
|
|
@@ -123,12 +154,12 @@ function VirtualGalleryTree(props) {
|
|
|
123
154
|
width: '100%',
|
|
124
155
|
height: virtualRow.size,
|
|
125
156
|
transform: `translateY(${virtualRow.start}px)`,
|
|
126
|
-
}, children: row.kind === 'folder' ? (_jsx(FolderRow, { node: row.node, depth: row.depth, effectCount: row.effectCount, open: open, onToggle: onToggleFolder })) : (_jsx(EffectRow, { entry: row.node.entry, depth: row.depth, focused: focusedRoot === row.node.entry.root, inPreview: previewRootIds.has(row.node.entry.root.uniqueId), onFocus: onFocus, onTogglePreview: onTogglePreview })) }, flatRowKey(row)));
|
|
157
|
+
}, children: row.kind === 'folder' ? (_jsx(FolderRow, { node: row.node, depth: row.depth, effectCount: row.effectCount, open: open, onToggle: onToggleFolder })) : (_jsx(EffectRow, { entry: row.node.entry, depth: row.depth, focused: focusedRoot === row.node.entry.root, inPreview: previewRootIds.has(row.node.entry.root.uniqueId), onFocus: onFocus, onTogglePreview: onTogglePreview, onRemoveFromScene: onRemoveFromScene })) }, flatRowKey(row)));
|
|
127
158
|
}) }) }));
|
|
128
159
|
}
|
|
129
160
|
/** Folder-tree catalog; import effects here and drag them into the viewport. */
|
|
130
161
|
export function GalleryPanel(props) {
|
|
131
|
-
const { entries, focusedRoot, previewRootIds, loading, folderInputRef, onFocus, onTogglePreview, onImportFiles, onImportFolder, onCreateDefault } = props;
|
|
162
|
+
const { entries, focusedRoot, previewRootIds, loading, folderInputRef, onFocus, onTogglePreview, onRemoveFromScene, onImportFiles, onImportFolder, onCreateDefault } = props;
|
|
132
163
|
const [query, setQuery] = useState('');
|
|
133
164
|
const [closedFolders, setClosedFolders] = useState(() => new Set());
|
|
134
165
|
const fileInputRef = useRef(null);
|
|
@@ -192,7 +223,7 @@ export function GalleryPanel(props) {
|
|
|
192
223
|
onImportFiles(files);
|
|
193
224
|
} })] })] }), _jsx("div", { style: { fontSize: 12, color: theme.textDim, lineHeight: 1.45 }, children: isLoading
|
|
194
225
|
? `Loading… ${loading.done} / ${loading.total || '…'} files`
|
|
195
|
-
: `${previewCount} previewing · ${inSceneCount}
|
|
226
|
+
: `${previewCount} previewing · ${inSceneCount} on stage · ${entries.length} in catalog` }), _jsxs("div", { style: { fontSize: 11, color: theme.textDim, lineHeight: 1.45 }, children: ["\u2713 stage preview \u00B7 \u2728 on stage \u00B7 drag to place \u00B7 \u00D7 remove from stage", _jsx("br", {}), "Click = edit \u00B7 Ctrl+click = toggle preview"] }), _jsx("input", { className: "qe-hover", style: inputStyle, placeholder: "Filter by name or path\u2026", value: query, onChange: (e) => setQuery(e.target.value) }), entries.length === 0 && !isLoading ? (_jsxs("div", { style: {
|
|
196
227
|
flex: 1,
|
|
197
228
|
minHeight: 120,
|
|
198
229
|
padding: '14px 12px',
|
|
@@ -211,5 +242,5 @@ export function GalleryPanel(props) {
|
|
|
211
242
|
border: `1px solid ${theme.border}`,
|
|
212
243
|
borderRadius: 8,
|
|
213
244
|
background: theme.sectionBg,
|
|
214
|
-
}, children: "No matches." })) : (_jsx(VirtualGalleryTree, { rows: flatRows, closedFolders: closedFolders, focusedRoot: focusedRoot, previewRootIds: previewRootIds, onFocus: onFocus, onTogglePreview: onTogglePreview, onToggleFolder: toggleFolder }))] }));
|
|
245
|
+
}, children: "No matches." })) : (_jsx(VirtualGalleryTree, { rows: flatRows, closedFolders: closedFolders, focusedRoot: focusedRoot, previewRootIds: previewRootIds, onFocus: onFocus, onTogglePreview: onTogglePreview, onRemoveFromScene: onRemoveFromScene, onToggleFolder: toggleFolder }))] }));
|
|
215
246
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import type { BatchedRenderer } from 'babylon.quarks';
|
|
2
3
|
import type { Scene } from '@babylonjs/core/scene';
|
|
3
4
|
import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
|
|
4
5
|
import type { EffectBinding } from '../core/binding';
|
|
@@ -9,6 +10,7 @@ export interface TimelinePanelProps {
|
|
|
9
10
|
selectedNode: TransformNode | null;
|
|
10
11
|
onSelectNode: (node: TransformNode | null) => void;
|
|
11
12
|
scene: Scene;
|
|
13
|
+
renderer: BatchedRenderer;
|
|
12
14
|
playbackRef: React.MutableRefObject<PlaybackState>;
|
|
13
15
|
playheadRef: React.RefObject<HTMLDivElement>;
|
|
14
16
|
counterRef: React.RefObject<HTMLSpanElement>;
|
|
@@ -3,7 +3,7 @@ import React, { useMemo, useRef, useState } from 'react';
|
|
|
3
3
|
import { ArrowPathIcon, ForwardIcon, PauseIcon, PlayIcon, RectangleGroupIcon, } from '@heroicons/react/24/solid';
|
|
4
4
|
import { clearFocusFinished, getFocusElapsed, isFocusFinished, markFocusFinished, setFocusElapsed, } from '../core/playback';
|
|
5
5
|
import { groupNodes } from '../core/groups';
|
|
6
|
-
import { scrubFocusTo } from '../core/scrub';
|
|
6
|
+
import { pauseFocus, playFocus, scrubFocusTo, scrubFocusToSettled } from '../core/scrub';
|
|
7
7
|
import { computeTimelineRows, computeTimelineSpan } from '../core/timeline';
|
|
8
8
|
import { TimelineTrackRow } from './TimelineTrackRow';
|
|
9
9
|
import { iconStyle } from './icons';
|
|
@@ -29,7 +29,7 @@ function pickTickInterval(pxPerSecond) {
|
|
|
29
29
|
* apply only to the focused effect without stopping other previews.
|
|
30
30
|
*/
|
|
31
31
|
export function TimelinePanel(props) {
|
|
32
|
-
const { binding, selectedNode, onSelectNode, scene, playbackRef, playheadRef, counterRef, stagePlaying, setStagePlaying, speed, setSpeed, playbackEnabled = true } = props;
|
|
32
|
+
const { binding, selectedNode, onSelectNode, scene, renderer, playbackRef, playheadRef, counterRef, stagePlaying, setStagePlaying, speed, setSpeed, playbackEnabled = true } = props;
|
|
33
33
|
const [bodyWidthPx, setBodyWidthPx] = useState(600);
|
|
34
34
|
const [collapsedGroups, setCollapsedGroups] = useState(() => new Set());
|
|
35
35
|
const [hiddenSystems, setHiddenSystems] = useState(() => new Set());
|
|
@@ -106,12 +106,19 @@ export function TimelinePanel(props) {
|
|
|
106
106
|
if (anyLooping) {
|
|
107
107
|
shown = timelineSpan > 0 ? elapsed % timelineSpan : 0;
|
|
108
108
|
}
|
|
109
|
-
else if (
|
|
109
|
+
else if (isFocusFinished(state, focusRootId)) {
|
|
110
|
+
// Park at real simulation time — core may finish before the ruler's worst-case span.
|
|
111
|
+
shown = elapsed;
|
|
112
|
+
}
|
|
113
|
+
else if (elapsed >= timelineSpan) {
|
|
110
114
|
shown = timelineSpan;
|
|
111
|
-
const justFinishedPlaying = !state.scrubbing && state.stagePlaying && prevElapsedRef.current < timelineSpan
|
|
115
|
+
const justFinishedPlaying = !state.scrubbing && state.stagePlaying && prevElapsedRef.current < timelineSpan;
|
|
112
116
|
if (justFinishedPlaying) {
|
|
117
|
+
scrubFocusToSettled(binding, renderer, timelineSpan);
|
|
113
118
|
markFocusFinished(state, focusRootId);
|
|
114
119
|
setFocusElapsed(state, focusRootId, timelineSpan);
|
|
120
|
+
pauseFocus(binding);
|
|
121
|
+
scene.render();
|
|
115
122
|
}
|
|
116
123
|
}
|
|
117
124
|
prevElapsedRef.current = elapsed;
|
|
@@ -122,7 +129,7 @@ export function TimelinePanel(props) {
|
|
|
122
129
|
};
|
|
123
130
|
raf = requestAnimationFrame(tick);
|
|
124
131
|
return () => cancelAnimationFrame(raf);
|
|
125
|
-
}, [pxPerSecond, timelineSpan, anyLooping, playbackRef, playheadRef, focusRootId, playbackEnabled]);
|
|
132
|
+
}, [pxPerSecond, timelineSpan, anyLooping, playbackRef, playheadRef, focusRootId, playbackEnabled, binding, scene, renderer]);
|
|
126
133
|
const visibleRows = useMemo(() => {
|
|
127
134
|
let hideUntilDepth = null;
|
|
128
135
|
const visible = rows.filter((row) => {
|
|
@@ -156,17 +163,24 @@ export function TimelinePanel(props) {
|
|
|
156
163
|
return Math.min(timelineSpan, Math.max(0, x / pxPerSecond));
|
|
157
164
|
};
|
|
158
165
|
const applyScrub = (time) => {
|
|
159
|
-
scrubFocusTo(binding, time);
|
|
160
166
|
const state = playbackRef.current;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
else if (!anyLooping) {
|
|
167
|
+
const atEnd = !anyLooping && time >= timelineSpan;
|
|
168
|
+
const shown = atEnd ? timelineSpan : time;
|
|
169
|
+
if (atEnd) {
|
|
170
|
+
scrubFocusToSettled(binding, renderer, timelineSpan);
|
|
166
171
|
markFocusFinished(state, focusRootId);
|
|
172
|
+
pauseFocus(binding);
|
|
167
173
|
}
|
|
174
|
+
else {
|
|
175
|
+
scrubFocusTo(binding, renderer, time);
|
|
176
|
+
clearFocusFinished(state, focusRootId);
|
|
177
|
+
if (stagePlaying) {
|
|
178
|
+
playFocus(binding);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
setFocusElapsed(state, focusRootId, shown);
|
|
168
182
|
if (playheadRef.current) {
|
|
169
|
-
playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET +
|
|
183
|
+
playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET + shown * pxPerSecond}px`;
|
|
170
184
|
}
|
|
171
185
|
scene.render();
|
|
172
186
|
};
|
|
@@ -182,10 +196,13 @@ export function TimelinePanel(props) {
|
|
|
182
196
|
}
|
|
183
197
|
};
|
|
184
198
|
const restartFocus = () => {
|
|
185
|
-
scrubFocusTo(binding, 0);
|
|
199
|
+
scrubFocusTo(binding, renderer, 0);
|
|
186
200
|
const state = playbackRef.current;
|
|
187
201
|
setFocusElapsed(state, focusRootId, 0);
|
|
188
202
|
clearFocusFinished(state, focusRootId);
|
|
203
|
+
if (stagePlaying) {
|
|
204
|
+
playFocus(binding);
|
|
205
|
+
}
|
|
189
206
|
if (playheadRef.current) {
|
|
190
207
|
playheadRef.current.style.left = `${LABEL_WIDTH + TIMELINE_INSET}px`;
|
|
191
208
|
}
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {GalleryEntry} from './loadEffectGallery';
|
|
2
|
+
import {QuarksUtil} from 'babylon.quarks';
|
|
2
3
|
|
|
3
4
|
function isPreviewEntry(entry: GalleryEntry, previewRootIds: ReadonlySet<number>): boolean {
|
|
4
5
|
return entry.inScene && previewRootIds.has(entry.root.uniqueId);
|
|
@@ -8,7 +9,8 @@ function isPreviewEntry(entry: GalleryEntry, previewRootIds: ReadonlySet<number>
|
|
|
8
9
|
export function applyGalleryPlayback(
|
|
9
10
|
entries: GalleryEntry[],
|
|
10
11
|
previewRootIds: ReadonlySet<number>,
|
|
11
|
-
transportPlaying: boolean
|
|
12
|
+
transportPlaying: boolean,
|
|
13
|
+
focusFinishedRootIds: ReadonlySet<number> = new Set()
|
|
12
14
|
): void {
|
|
13
15
|
for (const entry of entries) {
|
|
14
16
|
for (const system of entry.systems) {
|
|
@@ -24,6 +26,9 @@ export function applyGalleryPlayback(
|
|
|
24
26
|
if (!isPreviewEntry(entry, previewRootIds)) {
|
|
25
27
|
continue;
|
|
26
28
|
}
|
|
29
|
+
if (focusFinishedRootIds.has(entry.root.uniqueId)) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
27
32
|
for (const system of entry.systems) {
|
|
28
33
|
system.play();
|
|
29
34
|
}
|
|
@@ -53,3 +58,33 @@ export function countGalleryParticles(entries: GalleryEntry[], previewRootIds: R
|
|
|
53
58
|
}
|
|
54
59
|
return count;
|
|
55
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pauses one-shot preview effects whose particle systems report `isFinished()`.
|
|
64
|
+
* Returns true when any entry was paused (caller should refresh renderer batches).
|
|
65
|
+
*/
|
|
66
|
+
export function pauseFinishedPreviews(
|
|
67
|
+
entries: GalleryEntry[],
|
|
68
|
+
previewRootIds: ReadonlySet<number>,
|
|
69
|
+
focusFinishedRootIds: ReadonlySet<number> = new Set()
|
|
70
|
+
): boolean {
|
|
71
|
+
let pausedAny = false;
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (!isPreviewEntry(entry, previewRootIds)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (focusFinishedRootIds.has(entry.root.uniqueId)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!QuarksUtil.isEffectFinished(entry.root)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
for (const system of entry.systems) {
|
|
83
|
+
if (!system.paused) {
|
|
84
|
+
system.pause();
|
|
85
|
+
pausedAny = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return pausedAny;
|
|
90
|
+
}
|
package/src/core/galleryScene.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import {Vector3} from '@babylonjs/core/Maths/math.vector';
|
|
2
2
|
import type {Scene} from '@babylonjs/core/scene';
|
|
3
|
+
import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
3
4
|
import type {BatchedRenderer} from 'babylon.quarks';
|
|
4
5
|
import type {GalleryEntry} from './loadEffectGallery';
|
|
5
6
|
import {DEFAULT_GALLERY_SPACING, galleryGridPosition} from './galleryLayout';
|
|
6
7
|
|
|
8
|
+
/** Clears live particles and freezes simulation for one placed catalog entry. */
|
|
9
|
+
export function clearGalleryEntrySimulation(entry: GalleryEntry, renderer: BatchedRenderer): void {
|
|
10
|
+
for (const system of entry.systems) {
|
|
11
|
+
system.restart();
|
|
12
|
+
system.pause();
|
|
13
|
+
}
|
|
14
|
+
renderer.refreshBatches();
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
/** Parents a catalog entry into the live scene and registers its systems with the renderer. */
|
|
8
18
|
export function placeGalleryEntryInScene(
|
|
9
19
|
entry: GalleryEntry,
|
|
@@ -37,6 +47,30 @@ export function placeGalleryEntriesAt(
|
|
|
37
47
|
});
|
|
38
48
|
}
|
|
39
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Unregisters a placed effect from the renderer and returns it to the hidden catalog.
|
|
52
|
+
* Systems stay alive for inspector editing; drag into the viewport to place again.
|
|
53
|
+
*/
|
|
54
|
+
export function removeGalleryEntryFromScene(
|
|
55
|
+
entry: GalleryEntry,
|
|
56
|
+
renderer: BatchedRenderer,
|
|
57
|
+
catalogRoot: TransformNode
|
|
58
|
+
): void {
|
|
59
|
+
if (!entry.inScene) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
clearGalleryEntrySimulation(entry, renderer);
|
|
64
|
+
|
|
65
|
+
for (const system of entry.systems) {
|
|
66
|
+
renderer.deleteSystem(system);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
entry.root.parent = catalogRoot;
|
|
70
|
+
entry.root.setEnabled(false);
|
|
71
|
+
entry.inScene = false;
|
|
72
|
+
}
|
|
73
|
+
|
|
40
74
|
/** Raycasts the editor ground plane from canvas/client coordinates. */
|
|
41
75
|
export function pickGroundPosition(scene: Scene, clientX: number, clientY: number): Vector3 {
|
|
42
76
|
const canvas = scene.getEngine().getRenderingCanvas();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type {BatchedRenderer} from 'babylon.quarks';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'quarksDebugRender';
|
|
4
|
+
|
|
5
|
+
/** Whether GPU/particle mismatch logging is enabled (localStorage quarksDebugRender=1). */
|
|
6
|
+
export function isRenderDebugEnabled(): boolean {
|
|
7
|
+
try {
|
|
8
|
+
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let lastMismatchKey = '';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Logs when live particle count is zero but a batch still reports GPU instances.
|
|
18
|
+
* Enable with: localStorage.setItem('quarksDebugRender', '1')
|
|
19
|
+
*/
|
|
20
|
+
export function logRenderMismatch(renderer: BatchedRenderer, liveParticleCount: number): void {
|
|
21
|
+
if (!isRenderDebugEnabled()) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const stats = renderer.getBatchRenderStats();
|
|
25
|
+
const instanceCount = stats.reduce((sum, row) => sum + row.instanceCount, 0);
|
|
26
|
+
const batchParticleCount = stats.reduce((sum, row) => sum + row.particleCount, 0);
|
|
27
|
+
if (liveParticleCount !== 0 && instanceCount === liveParticleCount) {
|
|
28
|
+
lastMismatchKey = '';
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (liveParticleCount === 0 && instanceCount === 0 && batchParticleCount === 0) {
|
|
32
|
+
lastMismatchKey = '';
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const key = `${liveParticleCount}|${instanceCount}|${batchParticleCount}|${stats.map((row) => `${row.batchIndex}:${row.instanceCount}/${row.particleCount}`).join(';')}`;
|
|
36
|
+
if (key === lastMismatchKey) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
lastMismatchKey = key;
|
|
40
|
+
console.warn('[quarks render debug] particle vs GPU instance mismatch', {
|
|
41
|
+
liveParticleCount,
|
|
42
|
+
batchParticleCount,
|
|
43
|
+
instanceCount,
|
|
44
|
+
batches: stats,
|
|
45
|
+
});
|
|
46
|
+
}
|
package/src/core/scrub.ts
CHANGED
|
@@ -7,17 +7,32 @@ function bindingSystems(binding: EffectBinding): ParticleSystem[] {
|
|
|
7
7
|
return [binding.system, ...binding.subSystems];
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/** Unpauses every system in the focused effect. */
|
|
11
|
+
export function playFocus(binding: EffectBinding): void {
|
|
12
|
+
for (const system of bindingSystems(binding)) {
|
|
13
|
+
system.play();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Freezes the focused effect at its current simulation state. */
|
|
18
|
+
export function pauseFocus(binding: EffectBinding): void {
|
|
19
|
+
for (const system of bindingSystems(binding)) {
|
|
20
|
+
system.pause();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
/**
|
|
11
25
|
* Scrub only the focused effect's systems — does not advance other preview effects
|
|
12
26
|
* via the shared BatchedRenderer.update loop.
|
|
13
27
|
*/
|
|
14
|
-
export function scrubFocusTo(binding: EffectBinding, time: number): void {
|
|
28
|
+
export function scrubFocusTo(binding: EffectBinding, renderer: BatchedRenderer, time: number): void {
|
|
15
29
|
const systems = bindingSystems(binding);
|
|
16
30
|
for (const system of systems) {
|
|
17
31
|
system.restart();
|
|
18
32
|
system.play();
|
|
19
33
|
}
|
|
20
34
|
if (time <= 0) {
|
|
35
|
+
renderer.refreshBatches();
|
|
21
36
|
return;
|
|
22
37
|
}
|
|
23
38
|
const fixedStep = time > WIDE_STEP_THRESHOLD ? 1 / 30 : 1 / 60;
|
|
@@ -33,6 +48,33 @@ export function scrubFocusTo(binding: EffectBinding, time: number): void {
|
|
|
33
48
|
system.update(remainder);
|
|
34
49
|
}
|
|
35
50
|
}
|
|
51
|
+
renderer.refreshBatches();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Scrub to `time`, then keep simulating the focused effect until every particle has died.
|
|
56
|
+
* Used when parking a one-shot at the timeline end so burst tails do not linger on screen.
|
|
57
|
+
*/
|
|
58
|
+
export function scrubFocusToSettled(binding: EffectBinding, renderer: BatchedRenderer, time: number): void {
|
|
59
|
+
scrubFocusTo(binding, renderer, time);
|
|
60
|
+
const systems = bindingSystems(binding);
|
|
61
|
+
const step = 1 / 60;
|
|
62
|
+
const maxExtra = 2;
|
|
63
|
+
let extra = 0;
|
|
64
|
+
while (extra < maxExtra) {
|
|
65
|
+
let alive = 0;
|
|
66
|
+
for (const system of systems) {
|
|
67
|
+
alive += system.particleNum;
|
|
68
|
+
}
|
|
69
|
+
if (alive === 0) {
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
for (const system of systems) {
|
|
73
|
+
system.update(step);
|
|
74
|
+
}
|
|
75
|
+
extra += step;
|
|
76
|
+
}
|
|
77
|
+
renderer.refreshBatches();
|
|
36
78
|
}
|
|
37
79
|
|
|
38
80
|
/**
|
|
@@ -46,6 +88,7 @@ export function scrubFocusTo(binding: EffectBinding, time: number): void {
|
|
|
46
88
|
export function scrubTo(binding: EffectBinding, renderer: BatchedRenderer, time: number): void {
|
|
47
89
|
binding.restart();
|
|
48
90
|
if (time <= 0) {
|
|
91
|
+
renderer.refreshBatches();
|
|
49
92
|
return;
|
|
50
93
|
}
|
|
51
94
|
const fixedStep = time > WIDE_STEP_THRESHOLD ? 1 / 30 : 1 / 60;
|
package/src/core/timeline.ts
CHANGED
|
@@ -83,8 +83,36 @@ export function computeTimelineRows(binding: EffectBinding): TimelineRow[] {
|
|
|
83
83
|
return rows;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/** Upper bound on startLife for timeline span (burst/one-shot tail). */
|
|
87
|
+
function estimateMaxStartLife(system: ParticleSystem): number {
|
|
88
|
+
const life = system.startLife;
|
|
89
|
+
if (life instanceof ConstantValue) {
|
|
90
|
+
return Math.max(0, life.value);
|
|
91
|
+
}
|
|
92
|
+
if (life instanceof IntervalValue) {
|
|
93
|
+
return Math.max(life.a, life.b);
|
|
94
|
+
}
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** When emission is burst-only, the effect window ends at the last burst time, not full duration. */
|
|
99
|
+
function estimateEmissionEnd(system: ParticleSystem): number {
|
|
100
|
+
const rate =
|
|
101
|
+
system.emissionOverTime instanceof ConstantValue ? system.emissionOverTime.value : null;
|
|
102
|
+
const perMeter =
|
|
103
|
+
system.emissionOverDistance instanceof ConstantValue ? system.emissionOverDistance.value : null;
|
|
104
|
+
if ((rate != null && rate > 0) || (perMeter != null && perMeter > 0)) {
|
|
105
|
+
return system.duration;
|
|
106
|
+
}
|
|
107
|
+
if (system.emissionBursts.length === 0) {
|
|
108
|
+
return system.duration;
|
|
109
|
+
}
|
|
110
|
+
return Math.max(...system.emissionBursts.map((burst) => burst.time));
|
|
111
|
+
}
|
|
112
|
+
|
|
86
113
|
/**
|
|
87
|
-
* Overall timeline ruler span —
|
|
114
|
+
* Overall timeline ruler span — worst-case visual tail per track (max startLife, last burst).
|
|
115
|
+
* Actual playback often ends earlier when interval lives sample short or tracks finish out of phase.
|
|
88
116
|
*/
|
|
89
117
|
export function computeTimelineSpan(rows: TimelineRow[]): number {
|
|
90
118
|
let span = 0;
|
|
@@ -92,7 +120,7 @@ export function computeTimelineSpan(rows: TimelineRow[]): number {
|
|
|
92
120
|
if (row.kind !== 'track') {
|
|
93
121
|
continue;
|
|
94
122
|
}
|
|
95
|
-
span = Math.max(span, row.startOffset + row.
|
|
123
|
+
span = Math.max(span, row.startOffset + estimateEmissionEnd(row.system) + estimateMaxStartLife(row.system));
|
|
96
124
|
}
|
|
97
125
|
return Math.max(span, MIN_TIMELINE_SPAN);
|
|
98
126
|
}
|