@gjsify/example-dom-three-geometry-teapot 0.28.0 → 0.29.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/package.json +9 -8
- package/src/assets/pisa/nx.png +0 -0
- package/src/assets/pisa/ny.png +0 -0
- package/src/assets/pisa/nz.png +0 -0
- package/src/assets/pisa/px.png +0 -0
- package/src/assets/pisa/py.png +0 -0
- package/src/assets/pisa/pz.png +0 -0
- package/src/assets/uv_grid_opengl.jpg +0 -0
- package/src/browser/browser-main.ts +4 -0
- package/src/browser/browser.ts +284 -0
- package/src/browser/index.html +11 -0
- package/src/browser/webgl.css +53 -0
- package/src/gjs/gjs.ts +27 -0
- package/src/gjs/teapot-window.blp +111 -0
- package/src/gjs/teapot-window.ts +145 -0
- package/src/three-demo.ts +242 -0
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/example-dom-three-geometry-teapot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Three.js geometry teapot example ported from refs/three/examples/webgl_geometry_teapot.html with Adwaita controls",
|
|
5
5
|
"main": "dist/gjs.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"files": [
|
|
8
|
-
"dist"
|
|
8
|
+
"dist",
|
|
9
|
+
"src"
|
|
9
10
|
],
|
|
10
11
|
"exports": {
|
|
11
12
|
"./browser": "./src/browser/browser.ts",
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
"build:assets": "gjsify copy src/browser/index.html src/browser/webgl.css src/assets dist/"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"@gjsify/webgl": "^0.
|
|
30
|
+
"@gjsify/webgl": "^0.29.0"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"@girs/adw-1": "^4.1.0",
|
|
@@ -34,12 +35,12 @@
|
|
|
34
35
|
"@girs/gjs": "^4.1.0",
|
|
35
36
|
"@girs/gobject-2.0": "^4.1.0",
|
|
36
37
|
"@girs/gtk-4.0": "^4.1.0",
|
|
37
|
-
"@gjsify/adwaita-icons": "^0.
|
|
38
|
-
"@gjsify/adwaita-web": "^0.
|
|
39
|
-
"@gjsify/cli": "^0.
|
|
40
|
-
"@gjsify/devtools": "^0.
|
|
38
|
+
"@gjsify/adwaita-icons": "^0.29.0",
|
|
39
|
+
"@gjsify/adwaita-web": "^0.29.0",
|
|
40
|
+
"@gjsify/cli": "^0.29.0",
|
|
41
|
+
"@gjsify/devtools": "^0.29.0",
|
|
41
42
|
"@gjsify/node-gi": "file:../../../packages/node-gi/node-gi",
|
|
42
|
-
"@gjsify/vite-plugin-blueprint": "^0.
|
|
43
|
+
"@gjsify/vite-plugin-blueprint": "^0.29.0",
|
|
43
44
|
"@types/node": "^25.9.2",
|
|
44
45
|
"@types/three": "^0.185.0",
|
|
45
46
|
"http-server": "^14.1.1",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Browser UI for three-geometry-teapot example.
|
|
2
|
+
// Mirrors the GJS/Adwaita UI from gjs/teapot-window.ts using @gjsify/adwaita-web.
|
|
3
|
+
|
|
4
|
+
import '@gjsify/adwaita-web'; // registers the custom elements + self-injects the stylesheet
|
|
5
|
+
import type { AdwOverlaySplitView, AdwHeaderBar } from '@gjsify/adwaita-web';
|
|
6
|
+
import { mediaPlaybackPauseSymbolic, mediaPlaybackStartSymbolic } from '@gjsify/adwaita-icons/actions';
|
|
7
|
+
import {
|
|
8
|
+
start,
|
|
9
|
+
TESS_VALUES,
|
|
10
|
+
SHADING_VALUES,
|
|
11
|
+
DEFAULT_TESS_INDEX,
|
|
12
|
+
DEFAULT_SHADING_INDEX,
|
|
13
|
+
type TeapotDemo,
|
|
14
|
+
} from '../three-demo.js';
|
|
15
|
+
|
|
16
|
+
export interface MountOptions {
|
|
17
|
+
/** Base path for loading texture assets (forwarded to three-demo). */
|
|
18
|
+
assetBase?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Handle returned by `mount()` so hosts (e.g. the website slideshow) can pause and resume rendering. */
|
|
22
|
+
export interface ShowcaseHandle {
|
|
23
|
+
pause(): void;
|
|
24
|
+
resume(): void;
|
|
25
|
+
readonly isPaused: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Parse a trusted literal SVG string into an SVGElement. */
|
|
29
|
+
function parseSvg(svgSource: string): SVGElement {
|
|
30
|
+
const doc = new DOMParser().parseFromString(svgSource, 'image/svg+xml');
|
|
31
|
+
return doc.documentElement as unknown as SVGElement;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Replace a button's icon with a freshly-parsed copy of the given SVG source. */
|
|
35
|
+
function setButtonIcon(btn: HTMLButtonElement, svgSource: string): void {
|
|
36
|
+
btn.replaceChildren(parseSvg(svgSource));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create the Adwaita teapot UI and mount it into the given container.
|
|
41
|
+
* The container receives an adw-window with sidebar controls and a WebGL canvas.
|
|
42
|
+
*/
|
|
43
|
+
export function mount(container: HTMLElement, options?: MountOptions): ShowcaseHandle {
|
|
44
|
+
// Build UI — mirrors GJS Blueprint structure
|
|
45
|
+
const win = document.createElement('adw-window');
|
|
46
|
+
win.setAttribute('width', '1100');
|
|
47
|
+
win.setAttribute('height', '700');
|
|
48
|
+
|
|
49
|
+
// Header bar (toggle button added after DOM connection below)
|
|
50
|
+
const headerBar = document.createElement('adw-header-bar') as AdwHeaderBar;
|
|
51
|
+
headerBar.setAttribute('title', 'Three.js Teapot');
|
|
52
|
+
|
|
53
|
+
// Sidebar toggle button
|
|
54
|
+
const toggleBtn = document.createElement('button');
|
|
55
|
+
toggleBtn.className = 'adw-header-btn adw-sidebar-toggle-icon active';
|
|
56
|
+
toggleBtn.title = 'Toggle Sidebar';
|
|
57
|
+
|
|
58
|
+
// Pause/Resume rendering button — placed in header bar end section.
|
|
59
|
+
const pauseBtn = document.createElement('button');
|
|
60
|
+
pauseBtn.className = 'adw-header-btn';
|
|
61
|
+
pauseBtn.title = 'Pause Rendering';
|
|
62
|
+
setButtonIcon(pauseBtn, mediaPlaybackPauseSymbolic);
|
|
63
|
+
|
|
64
|
+
// OverlaySplitView — sidebar + content
|
|
65
|
+
const splitView = document.createElement('adw-overlay-split-view') as AdwOverlaySplitView;
|
|
66
|
+
splitView.setAttribute('min-sidebar-width', '280');
|
|
67
|
+
splitView.setAttribute('max-sidebar-width', '400');
|
|
68
|
+
splitView.setAttribute('sidebar-width-fraction', '0.30');
|
|
69
|
+
splitView.setAttribute('show-sidebar', '');
|
|
70
|
+
|
|
71
|
+
// Sidebar content
|
|
72
|
+
const sidebarContent = document.createElement('div');
|
|
73
|
+
sidebarContent.setAttribute('slot', 'sidebar');
|
|
74
|
+
sidebarContent.className = 'adw-sidebar-content';
|
|
75
|
+
|
|
76
|
+
// Geometry group
|
|
77
|
+
const geoGroup = document.createElement('adw-preferences-group');
|
|
78
|
+
geoGroup.setAttribute('title', 'Geometry');
|
|
79
|
+
|
|
80
|
+
const tessRow = document.createElement('adw-combo-row');
|
|
81
|
+
tessRow.setAttribute('title', 'Tessellation Level');
|
|
82
|
+
tessRow.setAttribute('items', JSON.stringify(TESS_VALUES.map(String)));
|
|
83
|
+
tessRow.setAttribute('selected', String(DEFAULT_TESS_INDEX));
|
|
84
|
+
|
|
85
|
+
const lidRow = document.createElement('adw-switch-row');
|
|
86
|
+
lidRow.setAttribute('title', 'Display Lid');
|
|
87
|
+
lidRow.setAttribute('active', '');
|
|
88
|
+
|
|
89
|
+
const bodyRow = document.createElement('adw-switch-row');
|
|
90
|
+
bodyRow.setAttribute('title', 'Display Body');
|
|
91
|
+
bodyRow.setAttribute('active', '');
|
|
92
|
+
|
|
93
|
+
const bottomRow = document.createElement('adw-switch-row');
|
|
94
|
+
bottomRow.setAttribute('title', 'Display Bottom');
|
|
95
|
+
bottomRow.setAttribute('active', '');
|
|
96
|
+
|
|
97
|
+
const fitLidRow = document.createElement('adw-switch-row');
|
|
98
|
+
fitLidRow.setAttribute('title', 'Snug Lid');
|
|
99
|
+
|
|
100
|
+
const nonblinnRow = document.createElement('adw-switch-row');
|
|
101
|
+
nonblinnRow.setAttribute('title', 'Original Scale');
|
|
102
|
+
|
|
103
|
+
geoGroup.append(tessRow, lidRow, bodyRow, bottomRow, fitLidRow, nonblinnRow);
|
|
104
|
+
|
|
105
|
+
// Material group
|
|
106
|
+
const matGroup = document.createElement('adw-preferences-group');
|
|
107
|
+
matGroup.setAttribute('title', 'Material');
|
|
108
|
+
|
|
109
|
+
const shadingRow = document.createElement('adw-combo-row');
|
|
110
|
+
shadingRow.setAttribute('title', 'Shading');
|
|
111
|
+
shadingRow.setAttribute('items', JSON.stringify([...SHADING_VALUES]));
|
|
112
|
+
shadingRow.setAttribute('selected', String(DEFAULT_SHADING_INDEX));
|
|
113
|
+
|
|
114
|
+
matGroup.append(shadingRow);
|
|
115
|
+
|
|
116
|
+
sidebarContent.append(geoGroup, matGroup);
|
|
117
|
+
|
|
118
|
+
// GL container (content slot) — inline styles so the showcase
|
|
119
|
+
// is self-contained and works regardless of host CSS.
|
|
120
|
+
const glContainer = document.createElement('div');
|
|
121
|
+
glContainer.setAttribute('slot', 'content');
|
|
122
|
+
glContainer.id = 'gl-area-container';
|
|
123
|
+
glContainer.style.cssText = 'flex:1;position:relative;min-width:0;min-height:0';
|
|
124
|
+
|
|
125
|
+
const canvas = document.createElement('canvas');
|
|
126
|
+
canvas.id = 'webgl-canvas';
|
|
127
|
+
canvas.style.cssText = 'display:block;width:100%;height:100%;position:absolute;inset:0';
|
|
128
|
+
glContainer.append(canvas);
|
|
129
|
+
|
|
130
|
+
splitView.append(sidebarContent, glContainer);
|
|
131
|
+
win.append(headerBar, splitView);
|
|
132
|
+
container.append(win);
|
|
133
|
+
|
|
134
|
+
// Append toggle button to header bar start section AFTER DOM connection
|
|
135
|
+
const startSection = headerBar.startSection ?? headerBar.querySelector('.adw-header-bar-start');
|
|
136
|
+
if (startSection) {
|
|
137
|
+
startSection.appendChild(toggleBtn);
|
|
138
|
+
} else {
|
|
139
|
+
headerBar.prepend(toggleBtn);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const endSection = headerBar.endSection ?? headerBar.querySelector('.adw-header-bar-end');
|
|
143
|
+
if (endSection) {
|
|
144
|
+
endSection.appendChild(pauseBtn);
|
|
145
|
+
} else {
|
|
146
|
+
headerBar.append(pauseBtn);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Sidebar toggle wiring
|
|
150
|
+
toggleBtn.addEventListener('click', () => {
|
|
151
|
+
splitView.toggleSidebar();
|
|
152
|
+
toggleBtn.classList.toggle('active', splitView.showSidebar);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
splitView.addEventListener('sidebar-toggled', () => {
|
|
156
|
+
toggleBtn.classList.toggle('active', splitView.showSidebar);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Responsive breakpoints — mirror GJS Adw.Breakpoint behavior
|
|
160
|
+
let lastCollapsed: boolean | null = null;
|
|
161
|
+
new ResizeObserver(([entry]) => {
|
|
162
|
+
const width = entry.contentRect.width;
|
|
163
|
+
const shouldCollapse = width < 800;
|
|
164
|
+
if (shouldCollapse === lastCollapsed) return;
|
|
165
|
+
lastCollapsed = shouldCollapse;
|
|
166
|
+
splitView.collapsed = shouldCollapse;
|
|
167
|
+
splitView.showSidebar = !shouldCollapse;
|
|
168
|
+
toggleBtn.classList.toggle('active', !shouldCollapse);
|
|
169
|
+
}).observe(win);
|
|
170
|
+
|
|
171
|
+
// Sync canvas size with container and re-render on resize
|
|
172
|
+
// (needed when slide becomes visible after being display:none)
|
|
173
|
+
canvas.width = glContainer.clientWidth;
|
|
174
|
+
canvas.height = glContainer.clientHeight;
|
|
175
|
+
|
|
176
|
+
let demo: TeapotDemo | null = null;
|
|
177
|
+
// Buffers pause() calls that arrive before the demo exists.
|
|
178
|
+
let pendingPause = false;
|
|
179
|
+
new ResizeObserver(() => {
|
|
180
|
+
const w = glContainer.clientWidth;
|
|
181
|
+
const h = glContainer.clientHeight;
|
|
182
|
+
if (w > 0 && h > 0) {
|
|
183
|
+
canvas.width = w;
|
|
184
|
+
canvas.height = h;
|
|
185
|
+
if (!demo) {
|
|
186
|
+
demo = start(canvas, { assetBase: options?.assetBase });
|
|
187
|
+
connectControls(
|
|
188
|
+
demo,
|
|
189
|
+
tessRow as AdwRow,
|
|
190
|
+
shadingRow as AdwRow,
|
|
191
|
+
lidRow as AdwRow,
|
|
192
|
+
bodyRow as AdwRow,
|
|
193
|
+
bottomRow as AdwRow,
|
|
194
|
+
fitLidRow as AdwRow,
|
|
195
|
+
nonblinnRow as AdwRow,
|
|
196
|
+
);
|
|
197
|
+
if (pendingPause) {
|
|
198
|
+
demo.pause();
|
|
199
|
+
pendingPause = false;
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
demo.render();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}).observe(glContainer);
|
|
206
|
+
|
|
207
|
+
// Pause button wiring — toggles demo state and swaps the icon.
|
|
208
|
+
function updatePauseButton(paused: boolean): void {
|
|
209
|
+
setButtonIcon(pauseBtn, paused ? mediaPlaybackStartSymbolic : mediaPlaybackPauseSymbolic);
|
|
210
|
+
pauseBtn.title = paused ? 'Resume Rendering' : 'Pause Rendering';
|
|
211
|
+
}
|
|
212
|
+
pauseBtn.addEventListener('click', () => {
|
|
213
|
+
if (demo) {
|
|
214
|
+
if (demo.isPaused) demo.resume();
|
|
215
|
+
else demo.pause();
|
|
216
|
+
updatePauseButton(demo.isPaused);
|
|
217
|
+
} else {
|
|
218
|
+
pendingPause = !pendingPause;
|
|
219
|
+
updatePauseButton(pendingPause);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
get isPaused() {
|
|
225
|
+
return demo ? demo.isPaused : pendingPause;
|
|
226
|
+
},
|
|
227
|
+
pause() {
|
|
228
|
+
if (demo) {
|
|
229
|
+
demo.pause();
|
|
230
|
+
updatePauseButton(true);
|
|
231
|
+
} else {
|
|
232
|
+
pendingPause = true;
|
|
233
|
+
updatePauseButton(true);
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
resume() {
|
|
237
|
+
if (demo) {
|
|
238
|
+
demo.resume();
|
|
239
|
+
updatePauseButton(false);
|
|
240
|
+
} else {
|
|
241
|
+
pendingPause = false;
|
|
242
|
+
updatePauseButton(false);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Adwaita web components expose custom properties (.selected, .active) not in HTMLElement types.
|
|
249
|
+
type AdwRow = HTMLElement & Record<string, unknown>;
|
|
250
|
+
|
|
251
|
+
function connectControls(
|
|
252
|
+
demo: TeapotDemo,
|
|
253
|
+
tessRow: AdwRow,
|
|
254
|
+
shadingRow: AdwRow,
|
|
255
|
+
lidRow: AdwRow,
|
|
256
|
+
bodyRow: AdwRow,
|
|
257
|
+
bottomRow: AdwRow,
|
|
258
|
+
fitLidRow: AdwRow,
|
|
259
|
+
nonblinnRow: AdwRow,
|
|
260
|
+
) {
|
|
261
|
+
tessRow.addEventListener('notify::selected', () => {
|
|
262
|
+
demo.effectController.newTess = TESS_VALUES[tessRow.selected as number];
|
|
263
|
+
demo.render();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
shadingRow.addEventListener('notify::selected', () => {
|
|
267
|
+
demo.effectController.newShading = SHADING_VALUES[shadingRow.selected as number];
|
|
268
|
+
demo.render();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const toggleRows: Array<[AdwRow, 'lid' | 'body' | 'bottom' | 'fitLid' | 'nonblinn']> = [
|
|
272
|
+
[lidRow, 'lid'],
|
|
273
|
+
[bodyRow, 'body'],
|
|
274
|
+
[bottomRow, 'bottom'],
|
|
275
|
+
[fitLidRow, 'fitLid'],
|
|
276
|
+
[nonblinnRow, 'nonblinn'],
|
|
277
|
+
];
|
|
278
|
+
for (const [row, key] of toggleRows) {
|
|
279
|
+
row.addEventListener('notify::active', () => {
|
|
280
|
+
demo.effectController[key] = Boolean(row.active);
|
|
281
|
+
demo.render();
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Three.js Teapot — gjsify</title>
|
|
6
|
+
<link rel="stylesheet" href="./webgl.css" type="text/css" />
|
|
7
|
+
<link rel="stylesheet" href="./browser.css" type="text/css" />
|
|
8
|
+
<script src="./browser.js" type="module" defer></script>
|
|
9
|
+
</head>
|
|
10
|
+
<body></body>
|
|
11
|
+
</html>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
* {
|
|
2
|
+
margin: 0;
|
|
3
|
+
padding: 0;
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
html, body {
|
|
8
|
+
width: 100%;
|
|
9
|
+
height: 100%;
|
|
10
|
+
overflow: hidden;
|
|
11
|
+
background: #2d2d2d;
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
justify-content: center;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
adw-window {
|
|
18
|
+
max-width: 100%;
|
|
19
|
+
max-height: 100%;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.adw-window-body {
|
|
23
|
+
display: flex;
|
|
24
|
+
flex: 1;
|
|
25
|
+
min-height: 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.adw-sidebar {
|
|
29
|
+
width: 320px;
|
|
30
|
+
flex-shrink: 0;
|
|
31
|
+
overflow-y: auto;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.adw-sidebar-content {
|
|
35
|
+
padding: 12px;
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-direction: column;
|
|
38
|
+
gap: 12px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#gl-area-container {
|
|
42
|
+
flex: 1;
|
|
43
|
+
position: relative;
|
|
44
|
+
min-width: 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#gl-area-container canvas {
|
|
48
|
+
display: block;
|
|
49
|
+
width: 100%;
|
|
50
|
+
height: 100%;
|
|
51
|
+
position: absolute;
|
|
52
|
+
inset: 0;
|
|
53
|
+
}
|
package/src/gjs/gjs.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// GJS/Adwaita entry point for three-geometry-teapot example.
|
|
2
|
+
// Ported from refs/three/examples/webgl_geometry_teapot.html
|
|
3
|
+
|
|
4
|
+
import Adw from 'gi://Adw?version=1';
|
|
5
|
+
import Gio from 'gi://Gio?version=2.0';
|
|
6
|
+
|
|
7
|
+
import { installDevtools } from '@gjsify/devtools';
|
|
8
|
+
import { TeapotWindow } from './teapot-window.js';
|
|
9
|
+
|
|
10
|
+
const app = new Adw.Application({
|
|
11
|
+
application_id: 'gjsify.examples.three-geometry-teapot',
|
|
12
|
+
flags: Gio.ApplicationFlags.FLAGS_NONE,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
app.connect('startup', () => installDevtools(app));
|
|
16
|
+
|
|
17
|
+
app.connect('activate', () => {
|
|
18
|
+
let win = app.get_active_window();
|
|
19
|
+
if (!win) win = new TeapotWindow(app);
|
|
20
|
+
win.present();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// runAsync (NOT the sync run()): defers the blocking main loop to a macrotask so
|
|
24
|
+
// the synchronous view load doesn't hang on its spinner, and it is the required
|
|
25
|
+
// lifecycle on the `--app node` reverse bridge (a sync run() deadlocks there) as
|
|
26
|
+
// well as the GJS-recommended one (Gio.Application.runAsync). Runs on all runtimes.
|
|
27
|
+
await app.runAsync([]);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
using Gtk 4.0;
|
|
2
|
+
using Adw 1;
|
|
3
|
+
|
|
4
|
+
template $TeapotWindow: Adw.ApplicationWindow {
|
|
5
|
+
default-width: 1100;
|
|
6
|
+
default-height: 700;
|
|
7
|
+
title: "Three.js Teapot";
|
|
8
|
+
|
|
9
|
+
[breakpoint]
|
|
10
|
+
Adw.Breakpoint {
|
|
11
|
+
condition ("max-width: 799sp")
|
|
12
|
+
setters {
|
|
13
|
+
splitView.collapsed: true;
|
|
14
|
+
splitView.show-sidebar: false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
[breakpoint]
|
|
19
|
+
Adw.Breakpoint {
|
|
20
|
+
condition ("min-width: 800sp")
|
|
21
|
+
setters {
|
|
22
|
+
splitView.collapsed: false;
|
|
23
|
+
splitView.show-sidebar: true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
content: Gtk.Box {
|
|
28
|
+
orientation: vertical;
|
|
29
|
+
|
|
30
|
+
Adw.HeaderBar {
|
|
31
|
+
[start]
|
|
32
|
+
ToggleButton sidebarToggleButton {
|
|
33
|
+
icon-name: "sidebar-show-symbolic";
|
|
34
|
+
tooltip-text: "Toggle Sidebar";
|
|
35
|
+
active: bind splitView.show-sidebar;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
[end]
|
|
39
|
+
Gtk.Button pauseButton {
|
|
40
|
+
icon-name: "media-playback-pause-symbolic";
|
|
41
|
+
tooltip-text: "Pause Rendering";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
Adw.OverlaySplitView splitView {
|
|
46
|
+
sidebar-width-fraction: 0.30;
|
|
47
|
+
min-sidebar-width: 280;
|
|
48
|
+
max-sidebar-width: 400;
|
|
49
|
+
show-sidebar: bind sidebarToggleButton.active;
|
|
50
|
+
|
|
51
|
+
sidebar: Gtk.ScrolledWindow {
|
|
52
|
+
hscrollbar-policy: never;
|
|
53
|
+
|
|
54
|
+
Gtk.Box {
|
|
55
|
+
orientation: vertical;
|
|
56
|
+
spacing: 12;
|
|
57
|
+
margin-top: 12;
|
|
58
|
+
margin-bottom: 12;
|
|
59
|
+
margin-start: 12;
|
|
60
|
+
margin-end: 12;
|
|
61
|
+
|
|
62
|
+
Adw.PreferencesGroup {
|
|
63
|
+
title: "Geometry";
|
|
64
|
+
|
|
65
|
+
Adw.ComboRow tessRow {
|
|
66
|
+
title: "Tessellation Level";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Adw.SwitchRow lidRow {
|
|
70
|
+
title: "Display Lid";
|
|
71
|
+
active: true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
Adw.SwitchRow bodyRow {
|
|
75
|
+
title: "Display Body";
|
|
76
|
+
active: true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
Adw.SwitchRow bottomRow {
|
|
80
|
+
title: "Display Bottom";
|
|
81
|
+
active: true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
Adw.SwitchRow fitLidRow {
|
|
85
|
+
title: "Snug Lid";
|
|
86
|
+
active: false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
Adw.SwitchRow nonblinnRow {
|
|
90
|
+
title: "Original Scale";
|
|
91
|
+
active: false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
Adw.PreferencesGroup {
|
|
96
|
+
title: "Material";
|
|
97
|
+
|
|
98
|
+
Adw.ComboRow shadingRow {
|
|
99
|
+
title: "Shading";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
content: Gtk.Box glAreaContainer {
|
|
106
|
+
hexpand: true;
|
|
107
|
+
vexpand: true;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Adwaita window for the three.js teapot example.
|
|
2
|
+
// Uses a Blueprint template for the UI layout and WebGLBridge for WebGL.
|
|
3
|
+
|
|
4
|
+
import GObject from 'gi://GObject?version=2.0';
|
|
5
|
+
import Gtk from 'gi://Gtk?version=4.0';
|
|
6
|
+
import Adw from 'gi://Adw?version=1';
|
|
7
|
+
import { WebGLBridge } from '@gjsify/webgl';
|
|
8
|
+
import {
|
|
9
|
+
start,
|
|
10
|
+
TESS_VALUES,
|
|
11
|
+
SHADING_VALUES,
|
|
12
|
+
DEFAULT_TESS_INDEX,
|
|
13
|
+
DEFAULT_SHADING_INDEX,
|
|
14
|
+
type TeapotDemo,
|
|
15
|
+
} from '../three-demo.js';
|
|
16
|
+
import Template from './teapot-window.blp';
|
|
17
|
+
|
|
18
|
+
export class TeapotWindow extends Adw.ApplicationWindow {
|
|
19
|
+
declare private _glAreaContainer: Gtk.Box;
|
|
20
|
+
declare private _tessRow: Adw.ComboRow;
|
|
21
|
+
declare private _lidRow: Adw.SwitchRow;
|
|
22
|
+
declare private _bodyRow: Adw.SwitchRow;
|
|
23
|
+
declare private _bottomRow: Adw.SwitchRow;
|
|
24
|
+
declare private _fitLidRow: Adw.SwitchRow;
|
|
25
|
+
declare private _nonblinnRow: Adw.SwitchRow;
|
|
26
|
+
declare private _shadingRow: Adw.ComboRow;
|
|
27
|
+
declare private _splitView: Adw.OverlaySplitView;
|
|
28
|
+
declare private _sidebarToggleButton: Gtk.ToggleButton;
|
|
29
|
+
declare private _pauseButton: Gtk.Button;
|
|
30
|
+
|
|
31
|
+
/** Live demo reference; set once the WebGLBridge is ready. */
|
|
32
|
+
private _demo: TeapotDemo | null = null;
|
|
33
|
+
|
|
34
|
+
static {
|
|
35
|
+
GObject.registerClass(
|
|
36
|
+
{
|
|
37
|
+
GTypeName: 'TeapotWindow',
|
|
38
|
+
Template,
|
|
39
|
+
InternalChildren: [
|
|
40
|
+
'glAreaContainer',
|
|
41
|
+
'tessRow',
|
|
42
|
+
'lidRow',
|
|
43
|
+
'bodyRow',
|
|
44
|
+
'bottomRow',
|
|
45
|
+
'fitLidRow',
|
|
46
|
+
'nonblinnRow',
|
|
47
|
+
'shadingRow',
|
|
48
|
+
'splitView',
|
|
49
|
+
'sidebarToggleButton',
|
|
50
|
+
'pauseButton',
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
this,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
constructor(application: Adw.Application) {
|
|
58
|
+
super({ application });
|
|
59
|
+
|
|
60
|
+
// Set up ComboRow models
|
|
61
|
+
this._tessRow.set_model(Gtk.StringList.new(TESS_VALUES.map(String)));
|
|
62
|
+
this._tessRow.set_selected(DEFAULT_TESS_INDEX);
|
|
63
|
+
|
|
64
|
+
this._shadingRow.set_model(Gtk.StringList.new([...SHADING_VALUES]));
|
|
65
|
+
this._shadingRow.set_selected(DEFAULT_SHADING_INDEX);
|
|
66
|
+
|
|
67
|
+
// Create and insert WebGL widget
|
|
68
|
+
const glArea = new WebGLBridge();
|
|
69
|
+
glArea.set_hexpand(true);
|
|
70
|
+
glArea.set_vexpand(true);
|
|
71
|
+
glArea.installGlobals();
|
|
72
|
+
this._glAreaContainer.append(glArea);
|
|
73
|
+
|
|
74
|
+
// Expose GL area dimensions as innerWidth/innerHeight for three.js
|
|
75
|
+
Object.defineProperty(globalThis, 'innerWidth', {
|
|
76
|
+
get: () => glArea.get_allocated_width(),
|
|
77
|
+
configurable: true,
|
|
78
|
+
});
|
|
79
|
+
Object.defineProperty(globalThis, 'innerHeight', {
|
|
80
|
+
get: () => glArea.get_allocated_height(),
|
|
81
|
+
configurable: true,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Initialize three.js when GL context is ready
|
|
85
|
+
glArea.onReady((canvas) => {
|
|
86
|
+
glArea.grab_focus();
|
|
87
|
+
const ctx = glArea.get_context()!;
|
|
88
|
+
print(`Context version: OpenGL${ctx.get_use_es() ? ' ES' : ''} ${ctx.get_version().join('.')}`);
|
|
89
|
+
|
|
90
|
+
this._demo = start(canvas);
|
|
91
|
+
this.connectControls(this._demo);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Pause/Resume button — toggles demo state and swaps the icon.
|
|
95
|
+
this._pauseButton.connect('clicked', () => {
|
|
96
|
+
if (!this._demo) return;
|
|
97
|
+
if (this._demo.isPaused) {
|
|
98
|
+
this._demo.resume();
|
|
99
|
+
this._pauseButton.set_icon_name('media-playback-pause-symbolic');
|
|
100
|
+
this._pauseButton.set_tooltip_text('Pause Rendering');
|
|
101
|
+
} else {
|
|
102
|
+
this._demo.pause();
|
|
103
|
+
this._pauseButton.set_icon_name('media-playback-start-symbolic');
|
|
104
|
+
this._pauseButton.set_tooltip_text('Resume Rendering');
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private connectControls(demo: TeapotDemo) {
|
|
110
|
+
this._tessRow.connect('notify::selected', () => {
|
|
111
|
+
demo.effectController.newTess = TESS_VALUES[this._tessRow.selected];
|
|
112
|
+
demo.render();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
this._shadingRow.connect('notify::selected', () => {
|
|
116
|
+
demo.effectController.newShading = SHADING_VALUES[this._shadingRow.selected];
|
|
117
|
+
demo.render();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
this._lidRow.connect('notify::active', () => {
|
|
121
|
+
demo.effectController.lid = this._lidRow.active;
|
|
122
|
+
demo.render();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
this._bodyRow.connect('notify::active', () => {
|
|
126
|
+
demo.effectController.body = this._bodyRow.active;
|
|
127
|
+
demo.render();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
this._bottomRow.connect('notify::active', () => {
|
|
131
|
+
demo.effectController.bottom = this._bottomRow.active;
|
|
132
|
+
demo.render();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
this._fitLidRow.connect('notify::active', () => {
|
|
136
|
+
demo.effectController.fitLid = this._fitLidRow.active;
|
|
137
|
+
demo.render();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
this._nonblinnRow.connect('notify::active', () => {
|
|
141
|
+
demo.effectController.nonblinn = this._nonblinnRow.active;
|
|
142
|
+
demo.render();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Ported from refs/three/examples/webgl_geometry_teapot.html
|
|
2
|
+
// Original: MIT license, three.js contributors
|
|
3
|
+
// Reimplemented as platform-agnostic module for GJS and browser targets.
|
|
4
|
+
|
|
5
|
+
import * as THREE from 'three';
|
|
6
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
7
|
+
import { TeapotGeometry } from 'three/addons/geometries/TeapotGeometry.js';
|
|
8
|
+
|
|
9
|
+
export type ShadingMode = 'wireframe' | 'flat' | 'smooth' | 'glossy' | 'textured' | 'reflective';
|
|
10
|
+
|
|
11
|
+
export const TESS_VALUES = [2, 3, 4, 5, 6, 8, 10, 15, 20, 30, 40, 50] as const;
|
|
12
|
+
export const SHADING_VALUES: readonly ShadingMode[] = [
|
|
13
|
+
'wireframe',
|
|
14
|
+
'flat',
|
|
15
|
+
'smooth',
|
|
16
|
+
'glossy',
|
|
17
|
+
'textured',
|
|
18
|
+
'reflective',
|
|
19
|
+
];
|
|
20
|
+
export const DEFAULT_TESS_INDEX = 7; // "15"
|
|
21
|
+
export const DEFAULT_SHADING_INDEX = 3; // "glossy"
|
|
22
|
+
|
|
23
|
+
export interface TeapotEffectController {
|
|
24
|
+
newTess: number;
|
|
25
|
+
lid: boolean;
|
|
26
|
+
body: boolean;
|
|
27
|
+
bottom: boolean;
|
|
28
|
+
fitLid: boolean;
|
|
29
|
+
nonblinn: boolean;
|
|
30
|
+
newShading: ShadingMode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TeapotDemo {
|
|
34
|
+
readonly effectController: TeapotEffectController;
|
|
35
|
+
/** Call after changing effectController properties to schedule a re-render. */
|
|
36
|
+
render(): void;
|
|
37
|
+
/** Suppress future render() calls until `resume()` is called. */
|
|
38
|
+
pause(): void;
|
|
39
|
+
/** Flush any pending render queued during pause. */
|
|
40
|
+
resume(): void;
|
|
41
|
+
/** Current paused state. */
|
|
42
|
+
readonly isPaused: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StartOptions {
|
|
46
|
+
/** Base path for loading texture assets (default: './'). */
|
|
47
|
+
assetBase?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TEAPOT_SIZE = 300;
|
|
51
|
+
|
|
52
|
+
export function start(canvas: HTMLCanvasElement, options?: StartOptions): TeapotDemo {
|
|
53
|
+
const assetBase = options?.assetBase ?? './';
|
|
54
|
+
|
|
55
|
+
// Renderer
|
|
56
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- THREE.WebGLRenderer canvas option expects OffscreenCanvas; GJS canvas type is incompatible
|
|
57
|
+
const renderer = new THREE.WebGLRenderer({ canvas: canvas as any, antialias: true });
|
|
58
|
+
// Pass updateStyle=false: the canvas CSS width/height are managed by the
|
|
59
|
+
// host container (flex layout). We only want to update the drawing buffer.
|
|
60
|
+
renderer.setSize(canvas.width, canvas.height, false);
|
|
61
|
+
// Track the size we last handed the renderer. `renderer.domElement` IS this
|
|
62
|
+
// canvas, so the obvious `renderer.domElement.width !== w` compares a value
|
|
63
|
+
// with itself and is never true — the resize branch below would be dead and
|
|
64
|
+
// the camera aspect would stay frozen at the first allocation. (The pixel
|
|
65
|
+
// post-processing showcase already carries this shape; keep the two aligned.)
|
|
66
|
+
let prevW = canvas.width;
|
|
67
|
+
let prevH = canvas.height;
|
|
68
|
+
|
|
69
|
+
// Camera
|
|
70
|
+
const camera = new THREE.PerspectiveCamera(45, canvas.width / canvas.height, 1, 80000);
|
|
71
|
+
camera.position.set(-600, 550, 1300);
|
|
72
|
+
|
|
73
|
+
// Scene
|
|
74
|
+
const scene = new THREE.Scene();
|
|
75
|
+
scene.background = new THREE.Color(0xaaaaaa);
|
|
76
|
+
|
|
77
|
+
// Lights
|
|
78
|
+
const ambientLight = new THREE.AmbientLight(0x7c7c7c, 2.0);
|
|
79
|
+
scene.add(ambientLight);
|
|
80
|
+
|
|
81
|
+
const light = new THREE.DirectionalLight(0xffffff, 2.0);
|
|
82
|
+
light.position.set(0.32, 0.39, 0.7);
|
|
83
|
+
scene.add(light);
|
|
84
|
+
|
|
85
|
+
// Texture map
|
|
86
|
+
const textureMap = new THREE.TextureLoader().load(`${assetBase}assets/uv_grid_opengl.jpg`);
|
|
87
|
+
textureMap.wrapS = textureMap.wrapT = THREE.RepeatWrapping;
|
|
88
|
+
textureMap.anisotropy = 16;
|
|
89
|
+
textureMap.colorSpace = THREE.SRGBColorSpace;
|
|
90
|
+
|
|
91
|
+
// Reflection map (cubemap)
|
|
92
|
+
const path = `${assetBase}assets/pisa/`;
|
|
93
|
+
const urls = ['px.png', 'nx.png', 'py.png', 'ny.png', 'pz.png', 'nz.png'];
|
|
94
|
+
const textureCube = new THREE.CubeTextureLoader().setPath(path).load(urls);
|
|
95
|
+
|
|
96
|
+
// Materials
|
|
97
|
+
const materials: Record<ShadingMode, THREE.Material> = {
|
|
98
|
+
wireframe: new THREE.MeshBasicMaterial({ wireframe: true }),
|
|
99
|
+
flat: new THREE.MeshPhongMaterial({ specular: 0x000000, flatShading: true, side: THREE.DoubleSide }),
|
|
100
|
+
smooth: new THREE.MeshLambertMaterial({ side: THREE.DoubleSide }),
|
|
101
|
+
glossy: new THREE.MeshPhongMaterial({
|
|
102
|
+
color: 0xc0c0c0,
|
|
103
|
+
specular: 0x404040,
|
|
104
|
+
shininess: 300,
|
|
105
|
+
side: THREE.DoubleSide,
|
|
106
|
+
}),
|
|
107
|
+
textured: new THREE.MeshPhongMaterial({ map: textureMap, side: THREE.DoubleSide }),
|
|
108
|
+
reflective: new THREE.MeshPhongMaterial({ envMap: textureCube, side: THREE.DoubleSide }),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// OrbitControls
|
|
112
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- OrbitControls domElement type is HTMLElement; GJS canvas type is incompatible
|
|
113
|
+
const cameraControls = new OrbitControls(camera, canvas as any);
|
|
114
|
+
cameraControls.addEventListener('change', render);
|
|
115
|
+
|
|
116
|
+
// Effect controller — external GUI mutates this, then calls render()
|
|
117
|
+
const effectController: TeapotEffectController = {
|
|
118
|
+
newTess: 15,
|
|
119
|
+
lid: true,
|
|
120
|
+
body: true,
|
|
121
|
+
bottom: true,
|
|
122
|
+
fitLid: false,
|
|
123
|
+
nonblinn: false,
|
|
124
|
+
newShading: 'glossy',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Track last-rendered state for change detection
|
|
128
|
+
let tess = -1; // force initial creation
|
|
129
|
+
let bBottom: boolean;
|
|
130
|
+
let bLid: boolean;
|
|
131
|
+
let bBody: boolean;
|
|
132
|
+
let bFitLid: boolean;
|
|
133
|
+
let bNonBlinn: boolean;
|
|
134
|
+
let shading: ShadingMode;
|
|
135
|
+
let teapot: THREE.Mesh | undefined;
|
|
136
|
+
|
|
137
|
+
function createNewTeapot() {
|
|
138
|
+
if (teapot !== undefined) {
|
|
139
|
+
teapot.geometry.dispose();
|
|
140
|
+
scene.remove(teapot);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const geometry = new TeapotGeometry(
|
|
144
|
+
TEAPOT_SIZE,
|
|
145
|
+
tess,
|
|
146
|
+
effectController.bottom,
|
|
147
|
+
effectController.lid,
|
|
148
|
+
effectController.body,
|
|
149
|
+
effectController.fitLid,
|
|
150
|
+
!effectController.nonblinn,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
teapot = new THREE.Mesh(geometry, materials[shading]);
|
|
154
|
+
scene.add(teapot);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function doRender() {
|
|
158
|
+
// Check if parameters changed
|
|
159
|
+
if (
|
|
160
|
+
effectController.newTess !== tess ||
|
|
161
|
+
effectController.bottom !== bBottom ||
|
|
162
|
+
effectController.lid !== bLid ||
|
|
163
|
+
effectController.body !== bBody ||
|
|
164
|
+
effectController.fitLid !== bFitLid ||
|
|
165
|
+
effectController.nonblinn !== bNonBlinn ||
|
|
166
|
+
effectController.newShading !== shading
|
|
167
|
+
) {
|
|
168
|
+
tess = effectController.newTess;
|
|
169
|
+
bBottom = effectController.bottom;
|
|
170
|
+
bLid = effectController.lid;
|
|
171
|
+
bBody = effectController.body;
|
|
172
|
+
bFitLid = effectController.fitLid;
|
|
173
|
+
bNonBlinn = effectController.nonblinn;
|
|
174
|
+
shading = effectController.newShading;
|
|
175
|
+
|
|
176
|
+
createNewTeapot();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Reflective mode uses cubemap as background
|
|
180
|
+
if (shading === 'reflective') {
|
|
181
|
+
scene.background = textureCube;
|
|
182
|
+
} else {
|
|
183
|
+
scene.background = null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Handle resize — canvas.width/height are the GTK-owned drawing buffer
|
|
187
|
+
const w = canvas.width;
|
|
188
|
+
const h = canvas.height;
|
|
189
|
+
if (prevW !== w || prevH !== h) {
|
|
190
|
+
prevW = w;
|
|
191
|
+
prevH = h;
|
|
192
|
+
renderer.setSize(w, h, false);
|
|
193
|
+
camera.aspect = w / h;
|
|
194
|
+
camera.updateProjectionMatrix();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
renderer.render(scene, camera);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Render scheduling: coalesce multiple render requests into one frame.
|
|
201
|
+
// In GTK, the GL context is only current inside the frame pipeline (rAF),
|
|
202
|
+
// so direct calls from signal handlers would fail.
|
|
203
|
+
let renderPending = false;
|
|
204
|
+
let paused = false;
|
|
205
|
+
// Set when render() is called while paused, so resume() can flush it.
|
|
206
|
+
let deferredRender = false;
|
|
207
|
+
function render() {
|
|
208
|
+
if (paused) {
|
|
209
|
+
deferredRender = true;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (renderPending) return;
|
|
213
|
+
renderPending = true;
|
|
214
|
+
requestAnimationFrame(() => {
|
|
215
|
+
renderPending = false;
|
|
216
|
+
doRender();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Initial render
|
|
221
|
+
render();
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
effectController,
|
|
225
|
+
render,
|
|
226
|
+
get isPaused() {
|
|
227
|
+
return paused;
|
|
228
|
+
},
|
|
229
|
+
pause() {
|
|
230
|
+
if (paused) return;
|
|
231
|
+
paused = true;
|
|
232
|
+
},
|
|
233
|
+
resume() {
|
|
234
|
+
if (!paused) return;
|
|
235
|
+
paused = false;
|
|
236
|
+
if (deferredRender) {
|
|
237
|
+
deferredRender = false;
|
|
238
|
+
render();
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|