Add FxNode render graph playground

Add persistent render graph loadouts, grouped node authoring, code-appending apply behavior, explicit playground lighting, and resilient worker startup.

Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-24 17:45:48 +00:00
co-authored by heaust
parent 951449db0b
commit 6153223fd5
27 changed files with 4672 additions and 207 deletions
+16 -5
View File
@@ -163,11 +163,16 @@ func (app *application) savePlayground(response http.ResponseWriter, request *ht
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
return
}
request.Body = http.MaxBytesReader(response, request.Body, maximumCodeBytes+4096)
request.Body = http.MaxBytesReader(
response,
request.Body,
maximumCodeBytes+maximumLoadoutsBytes+4096,
)
var input struct {
ID string `json:"id"`
Title string `json:"title"`
Code string `json:"code"`
ID string `json:"id"`
Title string `json:"title"`
Code string `json:"code"`
Loadouts json.RawMessage `json:"loadouts"`
}
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
@@ -175,7 +180,13 @@ func (app *application) savePlayground(response http.ResponseWriter, request *ht
writeJSONError(response, "The save request is not valid.", http.StatusBadRequest)
return
}
result, err := app.store.save(request.Context(), input.ID, strings.TrimSpace(input.Title), input.Code)
result, err := app.store.save(
request.Context(),
input.ID,
strings.TrimSpace(input.Title),
input.Code,
string(input.Loadouts),
)
if err != nil {
writeJSONError(response, err.Error(), http.StatusBadRequest)
return
+54 -1
View File
@@ -2,6 +2,8 @@ package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -66,12 +68,52 @@ func TestHealth(t *testing.T) {
}
}
func TestPlaygroundStoreMigratesLoadouts(t *testing.T) {
path := filepath.Join(t.TempDir(), "legacy.db")
database, err := sql.Open("sqlite3", path)
if err != nil {
t.Fatal(err)
}
if _, err := database.Exec(`
CREATE TABLE playgrounds (
id TEXT NOT NULL,
revision INTEGER NOT NULL,
title TEXT NOT NULL,
code TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (id, revision)
)
`); err != nil {
database.Close()
t.Fatal(err)
}
if err := database.Close(); err != nil {
t.Fatal(err)
}
store, err := openPlaygroundStore(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
saved, err := store.save(context.Background(), "", "Migrated", "const migrated = true;", "")
if err != nil {
t.Fatal(err)
}
if string(saved.Loadouts) != "[]" {
t.Fatalf("loadouts = %s", saved.Loadouts)
}
}
func TestSaveAndLoadPlaygroundRevision(t *testing.T) {
app := testApplication(t)
handler := app.routes()
requestBody, _ := json.Marshal(map[string]string{
requestBody, _ := json.Marshal(map[string]any{
"title": "First scene",
"code": "const scene = true;",
"loadouts": []map[string]any{{
"id": "custom", "name": "Custom", "layout": map[string]any{},
}},
})
request := httptest.NewRequest(http.MethodPost, "/api/playgrounds", bytes.NewReader(requestBody))
response := httptest.NewRecorder()
@@ -118,6 +160,17 @@ func TestSaveAndLoadPlaygroundRevision(t *testing.T) {
if loaded.Code != "const scene = true;" || loaded.Title != "First scene" {
t.Fatalf("loaded = %#v", loaded)
}
var loadouts []struct {
ID string `json:"id"`
Name string `json:"name"`
Layout map[string]any `json:"layout"`
}
if err := json.Unmarshal(loaded.Loadouts, &loadouts); err != nil {
t.Fatal(err)
}
if len(loadouts) != 1 || loadouts[0].ID != "custom" || loadouts[0].Name != "Custom" || len(loadouts[0].Layout) != 0 {
t.Fatalf("loadouts = %s", loaded.Loadouts)
}
request = httptest.NewRequest(http.MethodGet, "/api/playgrounds/"+saved.ID+"/2", nil)
response = httptest.NewRecorder()
+48 -5
View File
@@ -16,15 +16,39 @@ const browser = {
target: "es2022",
};
const fxNodeSource = path.join(root, "vendor", "fxnode", "src");
const fxNodeImports = {
name: "yawn-fxnode-imports",
setup(builder) {
builder.onResolve({ filter: /^fxnode$/ }, () => ({
path: path.join(fxNodeSource, "index.ts"),
}));
builder.onResolve({ filter: /\?worker&url$/ }, () => ({
path: "fxnode-worker-url",
namespace: "yawn-fxnode-worker",
}));
builder.onLoad({ filter: /.*/, namespace: "yawn-fxnode-worker" }, () => ({
contents: 'export default "./fxnode-worker.js";',
loader: "js",
}));
},
};
const workerFiles = new Map([
[path.join(root, "core", "index.js"), "./worker.js"],
[
path.join(root, "core", "index.js"),
{
url: "./worker.js",
source: await fs.readFile(path.join(root, "core", "worker.js"), "utf8"),
},
],
[
path.join(root, "addons", "handles", "src", "importers", "gltf.ts"),
"./importer-worker.js",
{ url: "./importer-worker.js" },
],
[
path.join(root, "addons", "handles", "src", "bvh", "picking.ts"),
"./bvh-worker.js",
{ url: "./bvh-worker.js" },
],
]);
@@ -37,12 +61,20 @@ const workerURLs = {
let contents = await fs.readFile(source, "utf8");
const transformed = contents.replace(
/new Worker\(new URL\("\.\/worker\.(?:js|ts)", import\.meta\.url\),/,
`createYawnWorker(new URL(${JSON.stringify(worker)}, import.meta.url),`,
`createYawnWorker(new URL(${JSON.stringify(worker.url)}, import.meta.url), ${worker.source ? JSON.stringify(worker.source) : "undefined"},`,
);
if (transformed === contents)
throw new Error(`worker constructor not found in ${source}`);
contents = `
function createYawnWorker(url, options) {
function createYawnWorker(url, embeddedSource, options) {
if (embeddedSource) {
const moduleUrl = new URL("./pkg/yawn_core.js", url);
const source = embeddedSource.replace("./pkg/yawn_core.js", moduleUrl.href);
const bootstrap = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
const worker = new Worker(bootstrap, options);
URL.revokeObjectURL(bootstrap);
return worker;
}
if (url.origin === globalThis.location.origin) return new Worker(url, options);
const source = "import " + JSON.stringify(url.href) + ";";
const bootstrap = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
@@ -70,6 +102,17 @@ await Promise.all([
outfile: path.join(output, "handles.js"),
plugins: [workerURLs],
}),
build({
...browser,
entryPoints: [path.join(root, "addons", "fxnode_rg", "src", "index.ts")],
outfile: path.join(output, "fxnode-rg.js"),
plugins: [fxNodeImports],
}),
build({
...browser,
entryPoints: [path.join(fxNodeSource, "worker", "fxnode.worker.ts")],
outfile: path.join(output, "fxnode-worker.js"),
}),
build({
bundle: false,
entryPoints: [path.join(root, "core", "worker.js")],
+60 -13
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"errors"
"fmt"
"math/big"
@@ -17,18 +18,20 @@ import (
)
const (
maximumCodeBytes = 200 * 1024
maximumTitleRunes = 80
maximumCodeBytes = 200 * 1024
maximumLoadoutsBytes = 512 * 1024
maximumTitleRunes = 80
)
var playgroundIDPattern = regexp.MustCompile(`^[a-z0-9]{6,24}$`)
type playground struct {
ID string `json:"id"`
Revision int `json:"revision"`
Title string `json:"title"`
Code string `json:"code"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id"`
Revision int `json:"revision"`
Title string `json:"title"`
Code string `json:"code"`
Loadouts json.RawMessage `json:"loadouts"`
CreatedAt time.Time `json:"createdAt"`
}
type playgroundStore struct {
@@ -61,16 +64,45 @@ func (store *playgroundStore) initialize() error {
revision INTEGER NOT NULL CHECK (revision > 0),
title TEXT NOT NULL,
code TEXT NOT NULL,
loadouts TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
PRIMARY KEY (id, revision)
);
CREATE INDEX IF NOT EXISTS playgrounds_created_at
ON playgrounds (created_at DESC);
`)
if err != nil {
return err
}
rows, err := store.Query(`PRAGMA table_info(playgrounds)`)
if err != nil {
return err
}
hasLoadouts := false
for rows.Next() {
var position, notNull, primaryKey int
var name, kind string
var defaultValue any
if err := rows.Scan(&position, &name, &kind, &notNull, &defaultValue, &primaryKey); err != nil {
rows.Close()
return err
}
hasLoadouts = hasLoadouts || name == "loadouts"
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
if !hasLoadouts {
_, err = store.Exec(`ALTER TABLE playgrounds ADD COLUMN loadouts TEXT NOT NULL DEFAULT '[]'`)
}
return err
}
func (store *playgroundStore) save(ctx context.Context, id, title, code string) (playground, error) {
func (store *playgroundStore) save(ctx context.Context, id, title, code, loadouts string) (playground, error) {
if id != "" && !playgroundIDPattern.MatchString(id) {
return playground{}, errors.New("invalid playground id")
}
@@ -80,6 +112,16 @@ func (store *playgroundStore) save(ctx context.Context, id, title, code string)
if code == "" || len(code) > maximumCodeBytes || !utf8.ValidString(code) {
return playground{}, fmt.Errorf("code must contain 1 to %d bytes of UTF-8", maximumCodeBytes)
}
if loadouts == "" {
loadouts = "[]"
}
if len(loadouts) > maximumLoadoutsBytes || !utf8.ValidString(loadouts) {
return playground{}, fmt.Errorf("loadouts must contain at most %d bytes of UTF-8", maximumLoadoutsBytes)
}
var loadoutList []json.RawMessage
if err := json.Unmarshal([]byte(loadouts), &loadoutList); err != nil || loadoutList == nil {
return playground{}, errors.New("loadouts must be a JSON array")
}
if id == "" {
var err error
id, err = randomPlaygroundID()
@@ -105,11 +147,12 @@ func (store *playgroundStore) save(ctx context.Context, id, title, code string)
createdAt := time.Now().UTC().Truncate(time.Millisecond)
if _, err := transaction.ExecContext(
ctx,
`INSERT INTO playgrounds (id, revision, title, code, created_at) VALUES (?, ?, ?, ?, ?)`,
`INSERT INTO playgrounds (id, revision, title, code, loadouts, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
id,
revision,
title,
code,
loadouts,
createdAt.Format(time.RFC3339Nano),
); err != nil {
return playground{}, err
@@ -117,7 +160,10 @@ func (store *playgroundStore) save(ctx context.Context, id, title, code string)
if err := transaction.Commit(); err != nil {
return playground{}, err
}
return playground{ID: id, Revision: revision, Title: title, Code: code, CreatedAt: createdAt}, nil
return playground{
ID: id, Revision: revision, Title: title, Code: code,
Loadouts: json.RawMessage(loadouts), CreatedAt: createdAt,
}, nil
}
func (store *playgroundStore) get(ctx context.Context, id string, revision int) (playground, error) {
@@ -125,16 +171,17 @@ func (store *playgroundStore) get(ctx context.Context, id string, revision int)
return playground{}, sql.ErrNoRows
}
var result playground
var createdAt string
var createdAt, loadouts string
err := store.QueryRowContext(
ctx,
`SELECT id, revision, title, code, created_at FROM playgrounds WHERE id = ? AND revision = ?`,
`SELECT id, revision, title, code, loadouts, created_at FROM playgrounds WHERE id = ? AND revision = ?`,
id,
revision,
).Scan(&result.ID, &result.Revision, &result.Title, &result.Code, &createdAt)
).Scan(&result.ID, &result.Revision, &result.Title, &result.Code, &loadouts, &createdAt)
if err != nil {
return playground{}, err
}
result.Loadouts = json.RawMessage(loadouts)
result.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
return result, err
}
+2
View File
@@ -41,6 +41,8 @@ func loadPackages(directory string) (map[string]memoryAsset, error) {
for _, name := range []string{
"core.js",
"handles.js",
"fxnode-rg.js",
"fxnode-worker.js",
"worker.js",
"importer-worker.js",
"bvh-worker.js",
+3 -1
View File
@@ -11,6 +11,8 @@ func TestLoadPackages(t *testing.T) {
for _, name := range []string{
"core.js",
"handles.js",
"fxnode-rg.js",
"fxnode-worker.js",
"worker.js",
"importer-worker.js",
"bvh-worker.js",
@@ -29,7 +31,7 @@ func TestLoadPackages(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(packages) != 6 {
if len(packages) != 8 {
t.Fatalf("package count = %d", len(packages))
}
if content := string(packages["pkg/yawn_core_bg.wasm"].content); content != filepath.Join("pkg", "yawn_core_bg.wasm") {
+31 -10
View File
@@ -1,6 +1,7 @@
import {
AmbientLight,
ArcRotateCamera,
PointLight,
Scene,
importGltf,
} from "@yawn/handles";
@@ -223,24 +224,47 @@ async function openFile(file) {
scene = new Scene(canvas, {
arenaBytes: 384 * 1024 * 1024,
hdr: true,
clearColor: [0.025, 0.035, 0.045, 1],
});
await scene.ready;
scene.array("sceneAccent").row(0).set([1, 1, 1, 1]);
const meshes = await importGltf(scene, url);
if (!meshes.length)
throw new Error("The file does not contain any triangle meshes.");
const bounds = findBounds(scene, meshes);
const light = new AmbientLight(scene, {
color: [1, 0.94, 0.86],
intensity: 0.28,
});
const radius = Math.max(bounds.radius, 0.001);
const lights = [
new AmbientLight(scene, {
color: [0.82, 0.9, 1],
intensity: 0.22,
}),
new PointLight(scene, {
position: [
bounds.center[0] + radius * 2.4,
bounds.center[1] + radius * 3.1,
bounds.center[2] + radius * 2.2,
],
color: [1, 0.78, 0.56],
intensity: 300,
range: radius * 8,
}),
new PointLight(scene, {
position: [
bounds.center[0] - radius * 2.1,
bounds.center[1] + radius * 0.8,
bounds.center[2] - radius * 1.8,
],
color: [0.45, 0.72, 1],
intensity: 120,
range: radius * 7,
}),
];
const camera = new ArcRotateCamera(
scene,
cameraOptions(scene, meshes, canvas, bounds),
);
await Promise.all([light.ready, camera.ready]);
await Promise.all([...lights.map((light) => light.ready), camera.ready]);
elements.renderValue.textContent = "— ms";
elements.renderTime.hidden = false;
@@ -251,10 +275,7 @@ async function openFile(file) {
if (!profiling)
elements.renderTime.title =
"GPU timestamp queries are not supported by this adapter.";
else {
elements.renderTime.removeAttribute("title");
scene.array("sceneAccent").row(0)[3] = 1;
}
else elements.renderTime.removeAttribute("title");
activeViewer = {
camera,
+62 -1
View File
@@ -42,6 +42,8 @@ button { color: inherit; }
.tool-group button { display: inline-flex; height: 29px; align-items: center; gap: 6px; padding: 0 9px; color: #586d80; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: pointer; font-size: 10px; }
.tool-group button:hover, .tool-group button[aria-pressed="true"] { color: #187fc2; border-color: #d3e3ed; background: #fff; }
.braces { color: #2498df; font: 700 10px/1 ui-monospace, monospace; }
.graph-tool-hint { display: inline-flex; height: 27px; align-items: center; gap: 5px; padding: 0 9px; color: #6b8193; font-size: 9px; }
.graph-tool-hint b { color: #258dcf; font-weight: 700; }
.tool-context { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: center; gap: 7px; color: #748698; font-size: 9px; }
.language-dot { width: 6px; height: 6px; border-radius: 2px; background: #2d9de3; }
.context-divider { width: 1px; height: 12px; margin-inline: 4px; background: #cfdae3; }
@@ -55,6 +57,10 @@ button { color: inherit; }
.pane-tab { display: flex; min-width: 145px; align-items: center; gap: 7px; padding: 0 14px; color: #9db1c2; border: 0; border-right: 1px solid #22384b; background: #0d1d2d; font-size: 10px; }
.pane-tab.active { color: #d4e2ed; border-top: 2px solid #2ba5ea; background: #0b1927; }
.ts-icon { display: grid; width: 18px; height: 16px; place-items: center; color: #fff; border-radius: 2px; background: #278dcd; font: 700 7px/1 ui-monospace, monospace; }
.rg-tab-icon { display: grid; width: 18px; height: 16px; place-items: center; color: #b8e6fb; border: 1px solid #277da9; border-radius: 3px; background: #16425c; font-size: 10px; }
#graph-tab-dirty { display: none; width: 6px; height: 6px; border-radius: 50%; background: #4fbceb; }
#graph-tab-dirty.visible { display: inline-block; }
.coding-panel { min-height: 0; }
.editor { position: relative; min-height: 0; overflow: hidden; background: #0b1927; }
.editor-loading { display: grid; height: 100%; place-items: center; color: #668097; font: 10px/1 ui-monospace, monospace; }
.monaco-editor .margin { background-color: #0b1927 !important; }
@@ -64,6 +70,57 @@ button { color: inherit; }
#editor-diagnostics i { width: 5px; height: 5px; border-radius: 50%; background: #4aa975; }
#editor-diagnostics.has-errors { color: #e8a4a4; }
#editor-diagnostics.has-errors i { background: #e26f6f; }
.render-graph-editor { display: grid; min-height: 0; grid-template-rows: 46px minmax(0, 1fr); overflow: hidden; background: #081522; }
.render-graph-toolbar { position: relative; z-index: 2; display: flex; min-width: 0; align-items: center; gap: 8px; padding: 6px 8px; border-bottom: 1px solid #1d3548; background: #0d2030; box-shadow: 0 5px 18px rgb(0 0 0 / 10%); }
.render-graph-toolbar label { position: relative; display: flex; min-width: 0; height: 31px; align-items: center; }
.render-graph-toolbar label > span { position: absolute; z-index: 1; top: 4px; left: 9px; color: #54758c; font: 700 6px/1 ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; pointer-events: none; }
.render-graph-toolbar select, .render-graph-toolbar input { height: 31px; padding: 11px 28px 3px 9px; color: #c2d7e5; border: 1px solid #29465c; border-radius: 5px; outline: 0; background: #10283a; font-size: 9px; }
.render-graph-toolbar select { width: 138px; cursor: pointer; }
.render-graph-toolbar input { width: 120px; padding-right: 8px; }
.render-graph-toolbar select:focus, .render-graph-toolbar input:focus { border-color: #318fc8; box-shadow: 0 0 0 2px rgb(49 155 215 / 12%); }
.render-graph-toolbar button { display: inline-flex; height: 31px; flex: 0 0 auto; align-items: center; gap: 5px; padding: 0 10px; color: #dff4ff; border: 1px solid #238ac8; border-radius: 5px; background: #187cb7; cursor: pointer; font-size: 8px; font-weight: 700; }
.render-graph-toolbar button:hover { background: #208dca; }
.render-graph-toolbar button.secondary { color: #a9c7d9; border-color: #31536a; background: #132d3f; }
.render-graph-toolbar button.secondary:hover { color: #e0f4ff; border-color: #39779c; background: #18394f; }
.render-graph-toolbar button:disabled { cursor: wait; opacity: .55; }
#loadout-status { display: inline-flex; min-width: 0; align-items: center; gap: 6px; margin-left: auto; overflow: hidden; color: #7590a3; font: 7px/1 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
#loadout-status i { width: 5px; height: 5px; flex: 0 0 auto; border-radius: 50%; background: #44ad78; box-shadow: 0 0 0 3px rgb(68 173 120 / 10%); }
#loadout-status.busy i { background: #3aa9e5; animation: status-pulse 1s infinite; }
#loadout-status.failed { color: #df9696; }
#loadout-status.failed i { background: #df6c6c; }
.render-graph-canvas-wrap { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
#render-graph-canvas { display: block; width: 100%; height: 100%; outline: 0; touch-action: none; }
#render-graph-canvas:focus-visible { box-shadow: inset 0 0 0 1px #3ba8e4; }
.render-graph-empty-hint { position: absolute; right: 12px; bottom: 12px; display: grid; grid-template-columns: 24px auto; gap: 2px 8px; align-items: center; padding: 8px 10px; color: #718b9f; border: 1px solid #203e53; border-radius: 7px; background: rgb(9 27 42 / 86%); box-shadow: 0 8px 25px rgb(0 0 0 / 16%); pointer-events: none; backdrop-filter: blur(6px); }
.render-graph-empty-hint > span { display: grid; width: 24px; height: 24px; grid-row: 1 / 3; place-items: center; color: #50b9ed; border: 1px solid #295b77; border-radius: 5px; background: #0f3046; }
.render-graph-empty-hint strong { color: #9bb4c6; font-size: 8px; }
.render-graph-empty-hint small { color: #577085; font-size: 7px; }
.rg-node-menu { position: fixed; z-index: 250; width: 226px; padding: 7px; visibility: hidden; color: #bcd0de; border: 1px solid #2d4c61; border-radius: 8px; background: #0d2030; box-shadow: 0 18px 50px rgb(0 0 0 / 40%); font-size: 9px; }
.rg-node-menu::before { position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 1px rgb(255 255 255 / 4%); content: ""; pointer-events: none; }
.rg-menu-search { display: flex; height: 31px; align-items: center; gap: 7px; margin-bottom: 6px; padding: 0 8px; color: #5d8098; border: 1px solid #28465b; border-radius: 5px; background: #091927; }
.rg-menu-search input { min-width: 0; flex: 1; color: #cce1ed; border: 0; outline: 0; background: transparent; font-size: 9px; }
.rg-menu-search input::placeholder { color: #557186; }
.rg-menu-groups { display: grid; gap: 2px; }
.rg-menu-group { position: relative; }
.rg-menu-group-trigger { display: grid; width: 100%; min-height: 34px; grid-template-columns: 24px 1fr auto; align-items: center; gap: 7px; padding: 0 7px; color: #a6bfce; text-align: left; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: default; }
.rg-menu-group-trigger:hover, .rg-menu-group.open > .rg-menu-group-trigger { color: #d9effb; border-color: #294b62; background: #153246; }
.rg-menu-group-icon { display: grid; width: 21px; height: 21px; place-items: center; color: #60c3f2; border: 1px solid #2a5a75; border-radius: 4px; background: #0e2a3e; font-size: 10px; }
.rg-menu-group-trigger small { color: #557287; font-size: 7px; }
.rg-menu-chevron { color: #56788f; }
.rg-menu-submenu { position: absolute; top: -7px; left: calc(100% + 7px); display: none; width: 252px; max-height: min(390px, calc(100vh - 20px)); padding: 7px; overflow-y: auto; border: 1px solid #2d4c61; border-radius: 8px; background: #0d2030; box-shadow: 0 18px 50px rgb(0 0 0 / 38%); }
.rg-node-menu.open-left .rg-menu-submenu { right: calc(100% + 7px); left: auto; }
.rg-menu-group.open > .rg-menu-submenu { display: grid; gap: 2px; }
.rg-menu-group:nth-child(n + 4) .rg-menu-submenu { top: auto; bottom: -7px; }
.rg-menu-submenu::before { position: absolute; top: 0; right: 100%; width: 8px; height: 42px; content: ""; }
.rg-menu-submenu > strong { padding: 5px 7px 7px; color: #5f8198; font: 700 7px/1 ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; }
.rg-menu-option { display: grid; min-height: 42px; grid-template-columns: 1fr auto; gap: 3px 8px; padding: 6px 8px; color: #a9c0cf; text-align: left; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: pointer; }
.rg-menu-option:hover, .rg-menu-option.active { color: #def3ff; border-color: #2b526a; background: #15364c; }
.rg-menu-option span { font-weight: 650; }
.rg-menu-option small { grid-column: 1 / 3; color: #607b8f; font-size: 7px; line-height: 1.3; }
.rg-menu-option b { color: #48b6eb; font-size: 11px; font-weight: 400; }
.rg-menu-results { display: grid; gap: 2px; max-height: min(390px, calc(100vh - 75px)); overflow-y: auto; }
.rg-menu-empty { padding: 17px 9px; color: #657e91; text-align: center; font-size: 8px; }
.resize-handle { position: relative; z-index: 3; cursor: col-resize; background: #172a3b; }
.resize-handle::after { position: absolute; top: 50%; left: 1px; width: 3px; height: 40px; transform: translateY(-50%); border-radius: 3px; background: #355067; content: ""; opacity: .65; }
.resize-handle:hover, .resize-handle.dragging { background: #218ed2; }
@@ -153,9 +210,13 @@ button { color: inherit; }
.run-button kbd { display: none; }
.tool-context { display: none; }
.tool-group-right { margin-left: auto; }
.workbench { display: grid; overflow: auto; grid-template-columns: 1fr; grid-template-rows: minmax(400px, 55vh) minmax(450px, 1fr); }
.workbench { display: grid; overflow: auto; grid-template-columns: 1fr; grid-template-rows: max(560px, 55vh) minmax(500px, 1fr); }
.resize-handle { display: none; }
.stage-pane { min-height: 500px; }
.render-graph-toolbar { overflow-x: auto; }
.loadout-name-field { display: none !important; }
.render-graph-empty-hint { display: none; }
.rg-menu-submenu { position: fixed; top: auto; right: 8px; bottom: 8px; left: 8px; width: auto; max-height: 42vh; }
}
@media (prefers-reduced-motion: reduce) {
+515 -17
View File
@@ -1,3 +1,12 @@
import {
compileFxNodeRenderGraph,
createRenderGraphEditor,
renderGraphNodeGroups,
renderGraphPresets,
} from "@yawn/fxnode-rg";
const renderGraphComment = "/* Render graph loadout:";
const defaultSource = `import {
AmbientLight,
ArcRotateCamera,
@@ -11,7 +20,11 @@ const defaultSource = `import {
SpotLight,
} from "@yawn/handles";
const scene = new Scene(canvas, { hdr: true, fps: 60 });
const scene = new Scene(canvas, {
hdr: true,
fps: 60,
clearColor: [0.018, 0.035, 0.055, 1],
});
await scene.ready;
await scene.reserve({ nodes: 6, materials: 2 });
@@ -62,15 +75,16 @@ const lights = [
outerAngle: 0.72,
}),
new PointLight(scene, {
position: [2.2, 0.9, 1.8],
color: [1, 0.02, 0.45],
intensity: 16,
range: 6,
position: [3.4, 1.8, 3.6],
color: [0.9, 0.04, 0.38],
intensity: 45,
range: 14,
radius: 1.5,
}),
];
await Promise.all(lights.map((light) => light.ready));
log("Icosphere ready — teal ambience, orange rim, and magenta accent are active.");
log("Icosphere ready — teal ambience, orange rim, and soft magenta fill are active.");
export default {
scene,
@@ -121,19 +135,27 @@ await scene.batchGraphUpdates(async () => {
const elements = Object.fromEntries(
[
"canvas",
"apply-loadout-button",
"clear-console",
"console-count",
"console-empty",
"console-lines",
"copy-loadout-code-button",
"cursor-status",
"dirty-indicator",
"editor",
"editor-diagnostics",
"editor-mode-status",
"encoding-status",
"format-button",
"fps-status",
"fullscreen-button",
"graph-tab-dirty",
"inspector",
"inspector-button",
"loadout-name",
"loadout-select",
"loadout-status",
"preview",
"preview-message",
"profile-adapter",
@@ -143,6 +165,9 @@ const elements = Object.fromEntries(
"profile-results",
"profile-total",
"project-title",
"render-graph-canvas",
"render-graph-editor",
"render-graph-hint",
"reset-button",
"resize-handle",
"resolution-status",
@@ -156,6 +181,7 @@ const elements = Object.fromEntries(
"tab-dirty",
"toast",
"type-status",
"workspace-language",
"workbench",
].map((id) => [id, document.getElementById(id)]),
);
@@ -222,14 +248,29 @@ let currentID = "";
let currentRevision = 0;
let initialSource = defaultSource;
let generation = 0;
let lastRunSource = "";
let dirty = false;
let activePanel = "console";
let activeCodingTab = "scene";
let stopProfile;
let profileCore;
let toastTimer;
let frameRequest;
let sampledFrame = 0;
let sampledAt = 0;
let renderGraphEditor;
let renderGraphMenu;
let renderGraphMenuOutsideListener;
let graphDirty = false;
let loadoutsDirty = false;
let suppressGraphChanges = false;
let customLoadouts = [];
let currentLoadoutID = "beauty";
let compiledRenderGraph = "";
localStorage.removeItem("yawn:playground:loadouts");
localStorage.removeItem("yawn:playground:active-loadout");
localStorage.removeItem("yawn:playground:draft");
addEventListener("yawn-theme-change", (event) => {
monaco?.editor.setTheme(event.detail.resolved === "dark" ? "yawn" : "vs");
@@ -242,6 +283,29 @@ function savedLocation() {
return match ? { id: match[1], revision: Number(match[2]) } : undefined;
}
function validCustomLoadouts(value) {
if (!Array.isArray(value)) return [];
return value
.filter(
(loadout) =>
loadout &&
typeof loadout.id === "string" &&
/^custom-[a-z0-9-]+$/u.test(loadout.id) &&
typeof loadout.name === "string" &&
loadout.name.trim() &&
loadout.layout &&
Array.isArray(loadout.layout.nodes) &&
Array.isArray(loadout.layout.links),
)
.slice(0, 40)
.map((loadout) => ({
id: loadout.id,
name: loadout.name.trim().slice(0, 64),
builtIn: false,
layout: loadout.layout,
}));
}
async function loadInitialPlayground() {
const saved = savedLocation();
if (saved) {
@@ -258,6 +322,7 @@ async function loadInitialPlayground() {
elements["revision-label"].textContent = `Revision ${result.revision}`;
elements["share-button"].hidden = false;
initialSource = result.code;
customLoadouts = validCustomLoadouts(result.loadouts);
document.title = `${result.title} — Yawn Playground`;
return result.code;
} catch (error) {
@@ -266,7 +331,8 @@ async function loadInitialPlayground() {
showToast(error.message);
}
}
return localStorage.getItem("yawn:playground:draft") || defaultSource;
customLoadouts = [];
return defaultSource;
}
function loadMonaco() {
@@ -367,13 +433,12 @@ async function createEditor(source) {
run,
});
editor.onDidChangeCursorPosition(({ position }) => {
if (activeCodingTab !== "scene") return;
elements["cursor-status"].textContent =
`Ln ${position.lineNumber}, Col ${position.column}`;
});
model.onDidChangeContent(() => {
markDirty(true);
if (!currentID)
localStorage.setItem("yawn:playground:draft", model.getValue());
});
monaco.editor.onDidChangeMarkers(([resource]) => {
if (resource.toString() === model.uri.toString()) updateDiagnostics();
@@ -384,6 +449,7 @@ async function createEditor(source) {
function updateDiagnostics() {
if (!monaco || !model) return;
if (activeCodingTab !== "scene") return;
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
const errors = markers.filter(
(marker) => marker.severity === monaco.MarkerSeverity.Error,
@@ -401,10 +467,403 @@ function updateDiagnostics() {
: "TypeScript language service ready";
}
function markDirty(value) {
function markDirty(value, target = "code") {
dirty = value;
elements["dirty-indicator"].classList.toggle("visible", value);
elements["tab-dirty"].classList.toggle("visible", value);
if (!value) {
elements["tab-dirty"].classList.remove("visible");
elements["graph-tab-dirty"].classList.remove("visible");
graphDirty = false;
loadoutsDirty = false;
} else if (target === "graph") {
elements["graph-tab-dirty"].classList.add("visible");
} else {
elements["tab-dirty"].classList.add("visible");
}
}
function allLoadouts() {
return [...renderGraphPresets, ...customLoadouts];
}
function activeLoadout() {
return allLoadouts().find((loadout) => loadout.id === currentLoadoutID);
}
function loadoutLayout(loadout = activeLoadout()) {
if (!loadout) throw new Error("The selected render graph loadout is missing");
return loadout.layout;
}
function renderLoadoutOptions() {
const builtIn = document.createElement("optgroup");
const saved = document.createElement("optgroup");
builtIn.label = "Prepared loadouts";
saved.label = "Playground loadouts";
for (const loadout of renderGraphPresets) {
const option = document.createElement("option");
option.value = loadout.id;
option.textContent = loadout.name;
builtIn.append(option);
}
for (const loadout of customLoadouts) {
const option = document.createElement("option");
option.value = loadout.id;
option.textContent = loadout.name;
saved.append(option);
}
elements["loadout-select"].replaceChildren(
builtIn,
...(customLoadouts.length ? [saved] : []),
);
elements["loadout-select"].value = currentLoadoutID;
const currentLoadout = activeLoadout();
elements["loadout-name"].value = currentLoadout?.name ?? "";
}
function setLoadoutStatus(message, state = "ready") {
elements["loadout-status"].className =
state === "busy" ? "busy" : state === "failed" ? "failed" : "";
elements["loadout-status"].title = message;
elements["loadout-status"].lastChild.textContent = ` ${message}`;
}
function markGraphChanged() {
if (suppressGraphChanges) return;
compiledRenderGraph = "";
graphDirty = true;
loadoutsDirty = true;
markDirty(true, "graph");
setLoadoutStatus("Unsaved graph changes");
}
function stageActiveLoadout(layout) {
const currentLoadout = activeLoadout();
if (!currentLoadout)
throw new Error("The selected render graph loadout is missing");
const name =
elements["loadout-name"].value.trim() ||
`${currentLoadout.name || "Render graph"} custom`;
let id = currentLoadout.builtIn ? "" : currentLoadout.id;
if (!id) id = `custom-${crypto.randomUUID().slice(0, 8)}`;
const changed =
currentLoadout.builtIn || graphDirty || currentLoadout.name !== name;
const staged = changed
? {
id,
name: name.slice(0, 64),
builtIn: false,
layout,
}
: currentLoadout;
if (changed) {
const index = customLoadouts.findIndex((loadout) => loadout.id === id);
if (index < 0) customLoadouts.push(staged);
else customLoadouts[index] = staged;
loadoutsDirty = true;
}
currentLoadoutID = id;
graphDirty = false;
renderLoadoutOptions();
if (changed) markDirty(true, "graph");
else elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
return staged;
}
function generatedRenderGraphBlock(name, serialized) {
const safeName = name.replace(/[\r\n]+/gu, " ").replaceAll("*/", "* /");
return `${renderGraphComment} ${safeName} */ { const renderGraph = ${JSON.stringify(serialized)}; const loadoutId = await scene.compileRenderGraph(renderGraph); await scene.setLoadout(loadoutId); }`;
}
function withAppendedRenderGraph(source, name, serialized) {
const block = generatedRenderGraphBlock(name, serialized);
return `${source.trimEnd()}\n\n${block}\n`;
}
async function compileLoadout(layout) {
const scene = current?.scene;
if (!scene?.getRenderGraph)
throw new Error("Run a Scene before compiling a render graph loadout");
return compileFxNodeRenderGraph(layout, await scene.getRenderGraph());
}
function closeRenderGraphMenu() {
if (renderGraphMenuOutsideListener) {
document.removeEventListener(
"pointerdown",
renderGraphMenuOutsideListener,
true,
);
renderGraphMenuOutsideListener = undefined;
}
renderGraphMenu?.remove();
renderGraphMenu = undefined;
}
function renderGraphOption(item, request, groupTitle) {
const button = document.createElement("button");
button.type = "button";
button.className = "rg-menu-option";
button.dataset.typeId = item.typeId;
button.dataset.search =
`${item.title} ${item.description} ${item.keywords || ""} ${groupTitle}`.toLowerCase();
const title = document.createElement("span");
const add = document.createElement("b");
const description = document.createElement("small");
title.textContent = item.title;
add.textContent = "+";
description.textContent = item.description;
button.append(title, add, description);
button.addEventListener("click", async () => {
closeRenderGraphMenu();
try {
await request.add(item.typeId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setLoadoutStatus(message, "failed");
showToast(message);
}
});
return button;
}
function openRenderGraphMenu(request) {
closeRenderGraphMenu();
const menu = document.createElement("div");
menu.className = "rg-node-menu";
menu.dataset.renderGraphMenu = "";
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Add render graph node");
const search = document.createElement("label");
search.className = "rg-menu-search";
search.innerHTML = '<span aria-hidden="true">⌕</span>';
const input = document.createElement("input");
input.type = "search";
input.placeholder = "Search nodes";
input.setAttribute("aria-label", "Search render graph nodes");
search.append(input);
const groups = document.createElement("div");
groups.className = "rg-menu-groups";
const results = document.createElement("div");
results.className = "rg-menu-results";
results.hidden = true;
const empty = document.createElement("div");
empty.className = "rg-menu-empty";
empty.textContent = "No render graph nodes match.";
empty.hidden = true;
const openGroup = (target) => {
for (const group of groups.children)
group.classList.toggle("open", group === target);
};
for (const group of renderGraphNodeGroups) {
const host = document.createElement("div");
host.className = "rg-menu-group";
host.dataset.group = group.id;
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "rg-menu-group-trigger";
trigger.setAttribute("aria-haspopup", "menu");
trigger.innerHTML = `<span class="rg-menu-group-icon">${group.icon}</span><span>${group.title}<br><small>${group.items.length} nodes</small></span><span class="rg-menu-chevron">›</span>`;
const submenu = document.createElement("div");
submenu.className = "rg-menu-submenu";
submenu.setAttribute("role", "menu");
const heading = document.createElement("strong");
heading.textContent = group.title;
submenu.append(
heading,
...group.items.map((item) =>
renderGraphOption(item, request, group.title),
),
);
trigger.addEventListener("pointerenter", () => openGroup(host));
trigger.addEventListener("click", () => openGroup(host));
host.append(trigger, submenu);
groups.append(host);
}
input.addEventListener("input", () => {
const query = input.value.trim().toLowerCase();
groups.hidden = Boolean(query);
results.hidden = !query;
results.replaceChildren();
if (query) {
const matches = renderGraphNodeGroups.flatMap((group) =>
group.items
.map((item) => renderGraphOption(item, request, group.title))
.filter((button) => button.dataset.search.includes(query)),
);
results.append(...matches);
empty.hidden = matches.length > 0;
} else {
empty.hidden = true;
}
});
input.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
closeRenderGraphMenu();
elements["render-graph-canvas"].focus();
}
});
menu.append(search, groups, results, empty);
document.body.append(menu);
const canvasBounds = elements["render-graph-canvas"].getBoundingClientRect();
const menuBounds = menu.getBoundingClientRect();
const margin = 8;
const x = canvasBounds.left + request.viewPosition.x;
const y = canvasBounds.top + request.viewPosition.y;
const needsLeftSubmenu = x + menuBounds.width + 270 > innerWidth - margin;
menu.classList.toggle("open-left", needsLeftSubmenu);
menu.style.left = `${Math.max(margin, Math.min(x, innerWidth - menuBounds.width - margin))}px`;
menu.style.top = `${Math.max(margin, Math.min(y, innerHeight - menuBounds.height - margin))}px`;
menu.style.visibility = "visible";
renderGraphMenu = menu;
input.focus();
renderGraphMenuOutsideListener = (event) => {
if (!menu.contains(event.target)) closeRenderGraphMenu();
};
document.addEventListener(
"pointerdown",
renderGraphMenuOutsideListener,
true,
);
}
async function switchLoadout(id) {
const next = allLoadouts().find((loadout) => loadout.id === id);
if (!next || !renderGraphEditor) return;
if (graphDirty) stageActiveLoadout(await renderGraphEditor.save());
suppressGraphChanges = true;
try {
currentLoadoutID = next.id;
await renderGraphEditor.load(loadoutLayout(next));
graphDirty = false;
elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
renderLoadoutOptions();
setLoadoutStatus(`${next.name} selected · Apply to update scene.ts`);
window.__yawnRenderGraph.selected = next.id;
window.__yawnRenderGraph.layout = loadoutLayout(next);
} finally {
suppressGraphChanges = false;
}
}
async function applyLoadout() {
if (!renderGraphEditor || elements["apply-loadout-button"].disabled) return;
elements["apply-loadout-button"].disabled = true;
try {
const layout = await renderGraphEditor.save();
const staged = stageActiveLoadout(layout);
if (model.getValue() !== lastRunSource && !(await run()))
throw new Error(
"The Scene must run successfully before applying a loadout",
);
setLoadoutStatus(`Compiling ${staged.name}…`, "busy");
compiledRenderGraph = await compileLoadout(layout);
const source = withAppendedRenderGraph(
model.getValue(),
staged.name,
compiledRenderGraph,
);
if (source !== model.getValue()) model.setValue(source);
window.__yawnRenderGraph.selected = staged.id;
window.__yawnRenderGraph.layout = structuredClone(layout);
window.__yawnRenderGraph.serialized = compiledRenderGraph;
if (!(await run()))
throw new Error("The generated render graph loadout could not run");
setLoadoutStatus(`${staged.name} applied from scene.ts`);
showToast(`Appended ${staged.name} to scene.ts and reran the playground`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setLoadoutStatus(message, "failed");
showToast(message);
} finally {
elements["apply-loadout-button"].disabled = false;
}
}
async function copyLoadoutCode() {
const button = elements["copy-loadout-code-button"];
if (!renderGraphEditor || button.disabled) return;
button.disabled = true;
try {
const layout = await renderGraphEditor.save();
compiledRenderGraph = await compileLoadout(layout);
const name = elements["loadout-name"].value.trim() || activeLoadout().name;
const code = generatedRenderGraphBlock(name, compiledRenderGraph);
await navigator.clipboard.writeText(code);
window.__yawnRenderGraph.serialized = compiledRenderGraph;
showToast(`${activeLoadout().name} loadout code copied`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setLoadoutStatus(message, "failed");
showToast(message);
} finally {
button.disabled = false;
}
}
async function initializeRenderGraphWorkspace() {
renderLoadoutOptions();
renderGraphEditor = await createRenderGraphEditor({
canvas: elements["render-graph-canvas"],
initialLayout: loadoutLayout(),
workerUrl: "/pkg/fxnode-worker.js",
onChange: markGraphChanged,
onAddNodeMenu: openRenderGraphMenu,
onError(error) {
const message = error instanceof Error ? error.message : String(error);
setLoadoutStatus(message, "failed");
},
});
window.__yawnRenderGraph = {
editor: renderGraphEditor,
layout: loadoutLayout(),
presets: renderGraphPresets.map(({ id, name }) => ({ id, name })),
selected: currentLoadoutID,
serialized: compiledRenderGraph,
switchLoadout,
};
setLoadoutStatus(`${activeLoadout().name} selected`);
}
function activateCodingTab(tab) {
activeCodingTab = tab;
closeRenderGraphMenu();
for (const button of document.querySelectorAll("[data-editor-tab]")) {
const active = button.dataset.editorTab === tab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
}
elements.editor.hidden = tab !== "scene";
elements["render-graph-editor"].hidden = tab !== "render-graph";
for (const target of document.querySelectorAll("[data-code-tool]"))
target.hidden = tab !== "scene";
for (const target of document.querySelectorAll("[data-graph-tool]"))
target.hidden = tab !== "render-graph";
if (tab === "render-graph") {
elements["workspace-language"].textContent = "Render graph";
elements["type-status"].textContent = "FxNode worker ready";
elements["cursor-status"].textContent = "Node canvas";
elements["editor-mode-status"].textContent = "FxNode";
elements["encoding-status"].textContent = "S-expression v1";
elements["editor-diagnostics"].lastChild.textContent =
" Graph valid on save";
elements["editor-diagnostics"].classList.remove("has-errors");
requestAnimationFrame(() => void renderGraphEditor?.syncViewport());
} else {
elements["workspace-language"].textContent = "TypeScript";
const position = editor?.getPosition();
elements["cursor-status"].textContent = position
? `Ln ${position.lineNumber}, Col ${position.column}`
: "Ln 1, Col 1";
elements["editor-mode-status"].textContent = "Spaces: 2";
elements["encoding-status"].textContent = "UTF-8";
updateDiagnostics();
editor?.layout();
editor?.focus();
}
}
async function transpile() {
@@ -414,10 +873,11 @@ async function transpile() {
const output = emitted.outputFiles.find((file) => file.name.endsWith(".js"));
if (emitted.emitSkipped || !output)
throw new Error("TypeScript could not emit this scene.");
return output.text
const code = output.text
.replace(/^\s*import\s+(?:[\s\S]*?\s+from\s+)?["'][^"']+["'];?\s*$/gm, "")
.replace(/^\s*export\s*\{\s*\};?\s*$/gm, "")
.replace(/\bexport\s+default\s+/, "return ");
.replace(/\bexport\s+default\s+/, "__yawnPlaygroundDefaultExport = ");
return `let __yawnPlaygroundDefaultExport;\n${code}\nreturn __yawnPlaygroundDefaultExport;`;
}
async function disposeCurrent() {
@@ -459,7 +919,7 @@ function replaceCanvas() {
}
async function run() {
if (!editor || elements["run-button"].disabled) return;
if (!editor || elements["run-button"].disabled) return false;
clearPlaygroundTimers();
const runID = ++generation;
elements["run-button"].disabled = true;
@@ -500,16 +960,18 @@ async function run() {
);
if (runID !== generation) {
if (result?.dispose) await result.dispose();
return;
return false;
}
current = result;
window.__yawnPlayground = result;
lastRunSource = model.getValue();
elements["preview-message"].hidden = true;
setRunStatus(
`Running · ${Math.round(performance.now() - started)} ms`,
"ready",
);
if (activePanel === "profile") await attachProfiler();
return true;
} catch (error) {
if (runID === generation) {
const message = error instanceof Error ? error.message : String(error);
@@ -518,6 +980,7 @@ async function run() {
showPreviewMessage("Scene could not start", message);
openInspector("console");
}
return false;
} finally {
if (runID === generation) {
elements["run-button"].disabled = false;
@@ -641,6 +1104,10 @@ async function save() {
if (!editor || elements["save-button"].disabled) return;
elements["save-button"].disabled = true;
try {
const hadLoadoutChanges = loadoutsDirty;
let stagedLoadout;
if (graphDirty && renderGraphEditor)
stagedLoadout = stageActiveLoadout(await renderGraphEditor.save());
const response = await fetch("/api/playgrounds", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -648,6 +1115,7 @@ async function save() {
id: currentID,
title: elements["project-title"].value.trim() || "Untitled playground",
code: model.getValue(),
loadouts: customLoadouts,
}),
});
const result = await response.json();
@@ -656,11 +1124,15 @@ async function save() {
currentID = result.id;
currentRevision = result.revision;
initialSource = result.code;
customLoadouts = validCustomLoadouts(result.loadouts);
renderLoadoutOptions();
history.pushState(null, "", `/playground/${result.id}/${result.revision}`);
elements["revision-label"].textContent = `Revision ${result.revision}`;
elements["share-button"].hidden = false;
localStorage.removeItem("yawn:playground:draft");
markDirty(false);
if (stagedLoadout) setLoadoutStatus("Saved · Apply to update scene.ts");
else if (hadLoadoutChanges)
setLoadoutStatus("Applied code · saved with playground");
showToast(`Saved revision ${result.revision}`);
} catch (error) {
showToast(error instanceof Error ? error.message : String(error));
@@ -718,6 +1190,23 @@ function sampleFPS(now = performance.now()) {
elements["run-button"].addEventListener("click", run);
elements["save-button"].addEventListener("click", save);
elements["apply-loadout-button"].addEventListener("click", applyLoadout);
elements["copy-loadout-code-button"].addEventListener("click", copyLoadoutCode);
elements["loadout-name"].addEventListener("input", markGraphChanged);
elements["loadout-select"].addEventListener(
"change",
(event) =>
void switchLoadout(event.target.value).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setLoadoutStatus(message, "failed");
showToast(message);
}),
);
for (const button of document.querySelectorAll("[data-editor-tab]")) {
button.addEventListener("click", () =>
activateCodingTab(button.dataset.editorTab),
);
}
elements["share-button"].addEventListener("click", async () => {
await navigator.clipboard.writeText(location.href);
showToast("Playground URL copied");
@@ -786,14 +1275,23 @@ elements["resize-handle"].addEventListener("pointerup", (event) => {
elements["resize-handle"].classList.remove("dragging");
});
addEventListener("popstate", () => location.reload());
addEventListener("beforeunload", () => {
addEventListener("beforeunload", (event) => {
if (!dirty) return;
event.preventDefault();
event.returnValue = "";
});
addEventListener("pagehide", (event) => {
if (event.persisted) return;
cancelAnimationFrame(frameRequest);
closeRenderGraphMenu();
void renderGraphEditor?.destroy();
void disposeCurrent();
});
try {
const source = await loadInitialPlayground();
await createEditor(source);
await initializeRenderGraphWorkspace();
markDirty(false);
sampleFPS();
await run();
+27 -15
View File
@@ -69,6 +69,7 @@ declare module "@yawn/handles" {
export interface SceneOptions {
arenaBytes?: number;
clearColor?: ArrayLike<number>;
debug?: boolean;
fps?: number;
hdr?: boolean;
@@ -78,6 +79,7 @@ declare module "@yawn/handles" {
readonly core: YawnCore;
readonly ready: Promise<this>;
readonly hdr: boolean;
readonly clearColor: readonly [number, number, number, number];
array(name: string): SharedRows;
ensureRows(
name: string,
@@ -88,6 +90,9 @@ declare module "@yawn/handles" {
reserve(additional: { nodes?: number; materials?: number }): Promise<void>;
batchWrites<T>(operation: () => T): T;
batchGraphUpdates<T>(operation: () => T | Promise<T>): Promise<T>;
compileRenderGraph(serialized: string): Promise<string>;
setLoadout(id: string): Promise<void>;
getRenderGraph(): Promise<object>;
dispose(): void;
}
@@ -140,12 +145,7 @@ declare module "@yawn/handles" {
}
export type VertexKind =
| "positions"
| "normals"
| "tangents"
| "uvs"
| "colors"
| "indices";
"positions" | "normals" | "tangents" | "uvs" | "colors" | "indices";
export interface MeshOptions extends NodeOptions {
geometryId?: number;
material?: PBRMaterial;
@@ -167,8 +167,10 @@ declare module "@yawn/handles" {
): Promise<this>;
}
export interface CubeOptions
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
export interface CubeOptions extends Omit<
MeshOptions,
"geometryId" | "vertexData"
> {
size?: number;
width?: number;
height?: number;
@@ -177,16 +179,20 @@ declare module "@yawn/handles" {
export class Cube extends Mesh {
constructor(scene: Scene, options?: CubeOptions);
}
export interface IcosphereOptions
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
export interface IcosphereOptions extends Omit<
MeshOptions,
"geometryId" | "vertexData"
> {
radius?: number;
subdivisions?: number;
}
export class Icosphere extends Mesh {
constructor(scene: Scene, options?: IcosphereOptions);
}
export interface PlaneOptions
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
export interface PlaneOptions extends Omit<
MeshOptions,
"geometryId" | "vertexData"
> {
size?: number;
width?: number;
depth?: number;
@@ -194,8 +200,10 @@ declare module "@yawn/handles" {
export class Plane extends Mesh {
constructor(scene: Scene, options?: PlaneOptions);
}
export interface SphereOptions
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
export interface SphereOptions extends Omit<
MeshOptions,
"geometryId" | "vertexData"
> {
radius?: number;
segments?: number;
rings?: number;
@@ -267,9 +275,13 @@ declare module "@yawn/handles" {
intensity?: number;
}
export class PointLight extends Node {
constructor(scene: Scene, options?: LightOptions & { range?: number });
constructor(
scene: Scene,
options?: LightOptions & { range?: number; radius?: number },
);
intensity: number;
range: number;
radius: number;
}
export class AmbientLight extends Node {
constructor(scene: Scene, options?: LightOptions);
+96 -11
View File
@@ -16,7 +16,8 @@
{
"imports": {
"@yawn/core": "/pkg/core.js",
"@yawn/handles": "/pkg/handles.js"
"@yawn/handles": "/pkg/handles.js",
"@yawn/fxnode-rg": "/pkg/fxnode-rg.js"
}
}
</script>
@@ -64,19 +65,22 @@
<div class="playground-tools">
<div class="tool-group">
<button id="snippets-button" type="button">
<button id="snippets-button" type="button" data-code-tool>
<span class="braces">{ }</span> Snippets
</button>
<button id="reset-button" type="button">
<button id="reset-button" type="button" data-code-tool>
<span aria-hidden="true">↶</span> Reset
</button>
<button id="format-button" type="button">
<button id="format-button" type="button" data-code-tool>
<span aria-hidden="true">≡</span> Format
</button>
<span class="graph-tool-hint" data-graph-tool hidden
><b>Right click</b> the graph to add a node</span
>
</div>
<div class="tool-context">
<span class="language-dot"></span>
<span>TypeScript</span>
<span id="workspace-language">TypeScript</span>
<span class="context-divider"></span>
<span id="type-status">Starting language service…</span>
</div>
@@ -95,19 +99,100 @@
</div>
<div class="workbench" id="workbench">
<section class="editor-pane" aria-label="TypeScript editor">
<div class="pane-tabs">
<button class="pane-tab active" type="button">
<section class="editor-pane" aria-label="Coding workspace">
<div class="pane-tabs" role="tablist" aria-label="Coding workspace">
<button
class="pane-tab active"
id="scene-tab"
type="button"
role="tab"
aria-selected="true"
aria-controls="editor"
data-editor-tab="scene"
>
<span class="ts-icon">TS</span> scene.ts <i id="tab-dirty"></i>
</button>
<button
class="pane-tab"
id="render-graph-tab"
type="button"
role="tab"
aria-selected="false"
aria-controls="render-graph-editor"
data-editor-tab="render-graph"
>
<span class="rg-tab-icon" aria-hidden="true">◇</span>
render graph
<i id="graph-tab-dirty"></i>
</button>
</div>
<div id="editor" class="editor">
<div
id="editor"
class="editor coding-panel active"
role="tabpanel"
aria-labelledby="scene-tab"
>
<div class="editor-loading">Loading TypeScript editor…</div>
</div>
<section
id="render-graph-editor"
class="render-graph-editor coding-panel"
role="tabpanel"
aria-labelledby="render-graph-tab"
hidden
>
<div class="render-graph-toolbar">
<label>
<span>Loadout</span>
<select
id="loadout-select"
aria-label="Render graph loadout"
></select>
</label>
<label class="loadout-name-field">
<span>Name</span>
<input
id="loadout-name"
maxlength="64"
placeholder="My loadout"
aria-label="Loadout name"
/>
</label>
<button
id="apply-loadout-button"
type="button"
title="Apply in memory; use the playground Save button to keep it"
>
<span aria-hidden="true">◇</span> Apply
</button>
<button
id="copy-loadout-code-button"
class="secondary"
type="button"
title="Copy this loadout's compiled S-expression as Scene code"
>
<span aria-hidden="true">⧉</span> Copy code
</button>
<output id="loadout-status" aria-live="polite"
><i></i> Standard active</output
>
</div>
<div class="render-graph-canvas-wrap">
<canvas
id="render-graph-canvas"
aria-label="Render graph node editor"
></canvas>
<div class="render-graph-empty-hint" id="render-graph-hint">
<span>+</span>
<strong>Add another pass</strong>
<small>Right click anywhere to browse the node catalog</small>
</div>
</div>
</section>
<footer class="editor-status">
<span id="cursor-status">Ln 1, Col 1</span>
<span>Spaces: 2</span>
<span>UTF-8</span>
<span id="editor-mode-status">Spaces: 2</span>
<span id="encoding-status">UTF-8</span>
<span class="status-spacer"></span>
<span id="editor-diagnostics"><i></i> No problems</span>
</footer>