Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
64e6768
generative: add GenerativeObject primitive + imagine() subsystem
salmanmkc Jun 23, 2026
9842e3a
generative: clean cutouts via background keying
salmanmkc Jun 23, 2026
7181262
demos: add generative_object proof demo
salmanmkc Jun 23, 2026
3557c41
docs(SKILL): document the generative objects primitive
salmanmkc Jun 23, 2026
4764527
demos(generative_object): enable Gemini (importmap + key entry)
salmanmkc Jun 23, 2026
36c8243
demos(generative_object): separate grab from summon
salmanmkc Jun 23, 2026
3de92c1
generative: billboard generated objects toward the user
salmanmkc Jun 23, 2026
1a22102
demos(generative_object): push-to-talk button + move status HUD
salmanmkc Jun 23, 2026
6c30fd8
generative: experimental 2.5D relief mode
salmanmkc Jun 23, 2026
ca199b7
demos(generative_object): R toggles 2.5D relief + add scene lights
salmanmkc Jun 23, 2026
fc8498b
generative: keep billboards upright (yaw-only facing)
salmanmkc Jun 24, 2026
6de2677
generative: add alpha-masked displacement map helper
salmanmkc Jun 24, 2026
313a15f
generative: build a relief displacement texture in the canvas source
salmanmkc Jun 24, 2026
a4230ff
generative: use the masked displacement map for relief
salmanmkc Jun 24, 2026
400e77e
generative: add groundOnSurface placement option
salmanmkc Jun 24, 2026
7b86657
generative: soften the default relief strength
salmanmkc Jun 24, 2026
b119e40
generative: ground objects on surfaces and occlude against depth
salmanmkc Jun 24, 2026
9e9b35f
demos(generative_object): spatial control panel, buttons, follow
salmanmkc Jun 24, 2026
c080cae
generative: make objects actually draggable (translating mode)
salmanmkc Jun 24, 2026
037833d
Merge branch 'main' into feat/generative-object
salmanmkc Jun 24, 2026
1febc5b
generative: trim verbose comments and docstrings
salmanmkc Jun 24, 2026
85db17e
Merge branch 'main' into feat/generative-object
salmanmkc Jun 25, 2026
94e41cb
Merge branch 'main' into feat/generative-object
salmanmkc Jun 26, 2026
cd7da30
Merge branch 'main' into feat/generative-object
salmanmkc Jun 27, 2026
8d0dda6
generative: drop the SDK subsystem wiring, keep only the tested helpers
salmanmkc Jun 27, 2026
8481609
demos: move generative objects into the demo as a TypeScript module
salmanmkc Jun 27, 2026
d952b11
demos(generative_object): place objects within reach and key any subject
salmanmkc Jun 27, 2026
8931882
generative_object: make button hover highlight visible
salmanmkc Jun 28, 2026
dfeee82
generative_object: stop the depth mesh stealing the reticle from the …
salmanmkc Jun 28, 2026
c597c67
generative_object: use ignoreReticleRaycast instead of patching depth…
salmanmkc Jun 28, 2026
c57be3b
generative_object: remove cleared objects' shaders from occludableSha…
salmanmkc Jun 28, 2026
bc382a0
generative_object: alpha-test the flat cutout to kill the chroma-key …
salmanmkc Jun 28, 2026
96ab500
generative_object: no-op depth mesh raycast for UI hover, not just th…
salmanmkc Jun 29, 2026
8740b79
Merge branch 'main' into feat/generative-object
ruofeidu Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions demos/generative_object/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<!doctype html>
<html lang="en">
<head>
<title>Generative Object</title>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<meta
http-equiv="Cache-Control"
content="no-cache, no-store, must-revalidate"
/>
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link type="text/css" rel="stylesheet" href="../../samples/main.css" />
<style>
.key-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, sans-serif;
color: #eee;
}
.key-card {
background: #1a1a1a;
padding: 24px 28px;
border-radius: 12px;
max-width: 440px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.key-card h2 {
margin: 0 0 8px;
font-size: 18px;
}
.key-card p {
margin: 0 0 16px;
font-size: 13px;
color: #aaa;
line-height: 1.4;
}
.key-card a {
color: #4a90e2;
}
.key-card input {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #555;
background: #111;
color: #eee;
font-size: 14px;
box-sizing: border-box;
margin-bottom: 12px;
}
.key-card button {
padding: 10px 20px;
border-radius: 8px;
border: none;
background: #4a90e2;
color: white;
cursor: pointer;
font-size: 14px;
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.module.js",
"three/": "https://cdn.jsdelivr.net/npm/three@0.184.0/",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.184.0/examples/jsm/",
"troika-three-text": "https://cdn.jsdelivr.net/gh/protectwise/troika@028b81cf308f0f22e5aa8e78196be56ec1997af5/packages/troika-three-text/src/index.js",
"troika-three-utils": "https://cdn.jsdelivr.net/gh/protectwise/troika@v0.52.4/packages/troika-three-utils/src/index.js",
"troika-worker-utils": "https://cdn.jsdelivr.net/gh/protectwise/troika@v0.52.4/packages/troika-worker-utils/src/index.js",
"bidi-js": "https://esm.sh/bidi-js@%5E1.0.2?target=es2022",
"webgl-sdf-generator": "https://esm.sh/webgl-sdf-generator@1.1.1/es2022/webgl-sdf-generator.mjs",
"lit": "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js",
"lit/": "https://esm.run/lit@3/",
"@pmndrs/uikit": "https://cdn.jsdelivr.net/npm/@pmndrs/uikit@1.0.64/dist/index.min.js",
"@pmndrs/uikit-pub-sub": "https://cdn.jsdelivr.net/npm/@pmndrs/uikit-pub-sub@1.0.64/dist/index.min.js",
"@pmndrs/msdfonts": "https://cdn.jsdelivr.net/npm/@pmndrs/msdfonts@1.0.64/dist/index.min.js",
"@preact/signals-core": "https://cdn.jsdelivr.net/npm/@preact/signals-core@1.12.1/dist/signals-core.mjs",
"yoga-layout/load": "https://cdn.jsdelivr.net/npm/yoga-layout@3.2.1/dist/src/load.js",
"uiblocks": "../../build/addons/uiblocks/src/index.js",
"xrblocks": "../../build/xrblocks.js",
"xrblocks/addons/": "../../build/addons/",
"@google/genai": "https://cdn.jsdelivr.net/npm/@google/genai/+esm"
}
}
</script>
</head>
<body>
<div id="keyOverlay" class="key-overlay" style="display: none">
<div class="key-card">
<h2>Gemini API key</h2>
<p>
This demo needs a Gemini API key with image generation access. Get one
from
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noopener"
>aistudio.google.com</a
>. The key is saved in your browser's localStorage and only sent to
Google.
</p>
<input id="keyInput" type="password" placeholder="AIza..." />
<button id="keySave">save and reload</button>
</div>
</div>

<script>
// Resolve gemini key: ?key= URL param > localStorage > ./keys.json > overlay.
(async function () {
const STORAGE_KEY = 'gemini-api-key';
const params = new URLSearchParams(window.location.search);
if (params.has('key')) {
window.localStorage.setItem(STORAGE_KEY, params.get('key'));
return;
}
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored) {
params.set('key', stored);
const newUrl =
window.location.pathname +
'?' +
params.toString() +
window.location.hash;
window.history.replaceState(null, '', newUrl);
return;
}
// Try the demo-local keys.json first, then the repo-root one (handy
// when debugging locally with a single keys.json at the served root).
for (const keysUrl of ['./keys.json', '../../keys.json']) {
try {
const res = await fetch(keysUrl, {cache: 'no-store'});
if (res.ok) {
const k = (await res.json()).key;
if (k) {
window.localStorage.setItem(STORAGE_KEY, k);
params.set('key', k);
window.location.search = '?' + params.toString();
return;
}
}
} catch (_) {
// no keys.json at this path, try the next / fall through to overlay
}
}
const overlay = document.getElementById('keyOverlay');
const input = document.getElementById('keyInput');
const save = document.getElementById('keySave');
overlay.style.display = 'flex';
const submit = () => {
const k = input.value.trim();
if (!k) return;
window.localStorage.setItem(STORAGE_KEY, k);
params.set('key', k);
window.location.search = '?' + params.toString();
};
save.addEventListener('click', submit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submit();
});
input.focus();
})();
</script>

<div
id="status"
style="
position: fixed;
top: 12px;
left: 12px;
z-index: 10;
max-width: 360px;
padding: 10px 14px;
border-radius: 8px;
background: rgba(20, 20, 30, 0.7);
color: #eee;
font-family: sans-serif;
font-size: 13px;
"
>
starting...
</div>
<script type="module" src="src/build/main.js"></script>
</body>
</html>
132 changes: 132 additions & 0 deletions demos/generative_object/src/GenerativeObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import * as THREE from 'three';
import {
computeBillboardScale,
DragMode,
type Draggable,
type HasDraggingMode,
OCCLUDABLE_ITEMS_LAYER,
Script,
} from 'xrblocks';

import type {LoadedTexture} from './TextureSource.js';

/** How to build a {@link GenerativeObject}'s mesh. */
export interface GenerativeObjectStyle {
/** Largest dimension of the object, in meters. */
maxSize: number;
/** Build a displaced 2.5D relief instead of a flat cutout. */
relief?: boolean;
/** Relief displacement depth in meters. */
reliefStrength?: number;
/** Plane subdivisions per side used for the relief mesh. */
reliefSegments?: number;
}

/**
* A generated image placed in the scene as a draggable object: a flat textured
* cutout by default, or a displaced relief mesh when
* {@link GenerativeObjectStyle.relief} is set. Opts into
* `OCCLUDABLE_ITEMS_LAYER` so depth occlusion can hide it behind real geometry.
*/
export class GenerativeObject
extends Script
implements Draggable, HasDraggingMode
{
draggable = true;
// Lets the global DragManager pick the object up and move it; without a
// draggingMode the manager bails out of beginDragging.
draggingMode = DragMode.TRANSLATING;

/** The prompt that produced this object. */
readonly prompt: string;

/** The mesh that renders the generated image. */
readonly mesh: THREE.Mesh<THREE.PlaneGeometry, THREE.Material>;

/**
* @param prompt - The prompt that produced the image.
* @param loaded - The decoded texture and its pixel dimensions.
* @param style - How to size and build the mesh.
*/
constructor(
prompt: string,
loaded: LoadedTexture,
style: GenerativeObjectStyle
) {
super();
this.prompt = prompt;

this.mesh = style.relief
? buildReliefMesh(loaded, style)
: buildFlatMesh(loaded.texture);

const size = computeBillboardScale(
loaded.width,
loaded.height,
style.maxSize
);
this.mesh.scale.set(size.x, size.y, 1);
this.add(this.mesh);

// Allow real-world depth to occlude the generated object.
this.mesh.layers.enable(OCCLUDABLE_ITEMS_LAYER);
}

/** Releases GPU resources held by this object. */
dispose() {
this.mesh.geometry.dispose();
const material = this.mesh.material as THREE.Material & {
map?: THREE.Texture | null;
displacementMap?: THREE.Texture | null;
bumpMap?: THREE.Texture | null;
};
// Dispose every distinct texture the material references (the relief mesh
// reuses one map across displacement + bump, so guard against double free).
const textures = new Set<THREE.Texture>();
for (const tex of [
material.map,
material.displacementMap,
material.bumpMap,
]) {
if (tex) textures.add(tex);
}
for (const tex of textures) tex.dispose();
material.dispose();
}
}

function buildFlatMesh(texture: THREE.Texture) {
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
// Discard the keyed-out (transparent) pixels so edge filtering doesn't blend
// the chroma-key background color into a halo around the cutout.
alphaTest: 0.5,
side: THREE.DoubleSide,
});
return new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
}

function buildReliefMesh(loaded: LoadedTexture, style: GenerativeObjectStyle) {
const segments = style.reliefSegments ?? 96;
const strength = style.reliefStrength ?? 0.04;
// Displace/bump from an alpha-masked grayscale map (background stays flat) so
// brighter subject regions stand out and pick up shading. Falls back to the
// color texture when no masked map is available.
const displacementMap = loaded.displacementTexture ?? loaded.texture;
const material = new THREE.MeshStandardMaterial({
map: loaded.texture,
displacementMap,
displacementScale: strength,
bumpMap: displacementMap,
roughness: 0.9,
metalness: 0,
transparent: true,
alphaTest: 0.5,
side: THREE.DoubleSide,
});
return new THREE.Mesh(
new THREE.PlaneGeometry(1, 1, segments, segments),
material
);
}
Loading