Skip to content

Commit 0b017e7

Browse files
mvaligurskyMartin Valigursky
andauthored
Simple gsplat viewer engine example with HDR controls (#8232)
* Simple gsplat viewer engine example with HDR controls * lint * thumbnails --------- Co-authored-by: Martin Valigursky <[email protected]>
1 parent c7b82eb commit 0b017e7

File tree

4 files changed

+344
-0
lines changed

4 files changed

+344
-0
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import * as pc from 'playcanvas';
2+
3+
/**
4+
* @param {import('../../app/components/Example.mjs').ControlOptions} options - The options.
5+
* @returns {JSX.Element} The returned JSX Element.
6+
*/
7+
export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
8+
const { BindingTwoWay, BooleanInput, LabelGroup, Panel, SelectInput, SliderInput } = ReactPCUI;
9+
return fragment(
10+
jsx(
11+
Panel,
12+
{ headerText: 'Scene' },
13+
jsx(
14+
LabelGroup,
15+
{ text: 'Skydome' },
16+
jsx(BooleanInput, {
17+
type: 'toggle',
18+
binding: new BindingTwoWay(),
19+
link: { observer, path: 'data.skydome' }
20+
})
21+
),
22+
jsx(
23+
LabelGroup,
24+
{ text: 'Orientation' },
25+
jsx(SelectInput, {
26+
binding: new BindingTwoWay(),
27+
link: { observer, path: 'data.orientation' },
28+
type: 'number',
29+
options: [
30+
{ v: 0, t: '0°' },
31+
{ v: 90, t: '90°' },
32+
{ v: 180, t: '180°' },
33+
{ v: 270, t: '270°' }
34+
]
35+
})
36+
)
37+
),
38+
jsx(
39+
Panel,
40+
{ headerText: 'Tone & Color' },
41+
jsx(
42+
LabelGroup,
43+
{ text: 'Tonemapping' },
44+
jsx(SelectInput, {
45+
binding: new BindingTwoWay(),
46+
link: { observer, path: 'data.tonemapping' },
47+
type: 'number',
48+
options: [
49+
{ v: pc.TONEMAP_LINEAR, t: 'LINEAR' },
50+
{ v: pc.TONEMAP_FILMIC, t: 'FILMIC' },
51+
{ v: pc.TONEMAP_HEJL, t: 'HEJL' },
52+
{ v: pc.TONEMAP_ACES, t: 'ACES' },
53+
{ v: pc.TONEMAP_ACES2, t: 'ACES2' },
54+
{ v: pc.TONEMAP_NEUTRAL, t: 'NEUTRAL' }
55+
]
56+
})
57+
),
58+
jsx(
59+
LabelGroup,
60+
{ text: 'Exposure (EV)' },
61+
jsx(SliderInput, {
62+
binding: new BindingTwoWay(),
63+
link: { observer, path: 'data.grading.exposure' },
64+
min: -5,
65+
max: 5,
66+
precision: 2
67+
})
68+
),
69+
jsx(
70+
LabelGroup,
71+
{ text: 'Contrast' },
72+
jsx(SliderInput, {
73+
binding: new BindingTwoWay(),
74+
link: { observer, path: 'data.grading.contrast' },
75+
min: 0.5,
76+
max: 1.5,
77+
precision: 2
78+
})
79+
)
80+
)
81+
);
82+
};
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { data } from 'examples/observer';
2+
import { deviceType, rootPath } from 'examples/utils';
3+
import * as pc from 'playcanvas';
4+
5+
const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
6+
window.focus();
7+
8+
// Create HTML overlay for drop instructions
9+
const dropOverlay = document.createElement('div');
10+
dropOverlay.id = 'drop-overlay';
11+
dropOverlay.style.cssText = `
12+
position: absolute;
13+
top: 0;
14+
left: 0;
15+
width: 100%;
16+
height: 100%;
17+
display: flex;
18+
align-items: center;
19+
justify-content: center;
20+
pointer-events: none;
21+
z-index: 1000;
22+
`;
23+
24+
const dropBox = document.createElement('div');
25+
dropBox.style.cssText = `
26+
background: rgba(0, 0, 0, 0.45);
27+
border: 2px dashed rgba(255, 255, 255, 0.6);
28+
border-radius: 16px;
29+
padding: 32px 48px;
30+
font-family: Arial, sans-serif;
31+
font-size: 24px;
32+
color: white;
33+
`;
34+
dropBox.textContent = 'Drop .ply or .sog file to view';
35+
dropOverlay.appendChild(dropBox);
36+
document.body.appendChild(dropOverlay);
37+
38+
const gfxOptions = {
39+
deviceTypes: [deviceType],
40+
// Disable antialiasing as CameraFrame handles it
41+
antialias: false
42+
};
43+
44+
const device = await pc.createGraphicsDevice(canvas, gfxOptions);
45+
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
46+
47+
const createOptions = new pc.AppOptions();
48+
createOptions.graphicsDevice = device;
49+
createOptions.mouse = new pc.Mouse(document.body);
50+
createOptions.touch = new pc.TouchDevice(document.body);
51+
52+
createOptions.componentSystems = [
53+
pc.RenderComponentSystem,
54+
pc.CameraComponentSystem,
55+
pc.LightComponentSystem,
56+
pc.ScriptComponentSystem,
57+
pc.GSplatComponentSystem
58+
];
59+
createOptions.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler, pc.ScriptHandler, pc.GSplatHandler];
60+
61+
const app = new pc.AppBase(canvas);
62+
app.init(createOptions);
63+
64+
// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size
65+
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
66+
app.setCanvasResolution(pc.RESOLUTION_AUTO);
67+
68+
// Ensure canvas is resized when window changes size
69+
const resize = () => app.resizeCanvas();
70+
window.addEventListener('resize', resize);
71+
app.on('destroy', () => {
72+
window.removeEventListener('resize', resize);
73+
});
74+
75+
// Load orbit camera script and HDRI
76+
const assets = {
77+
orbit: new pc.Asset('script', 'script', { url: `${rootPath}/static/scripts/camera/orbit-camera.js` }),
78+
hdri: new pc.Asset(
79+
'hdri',
80+
'texture',
81+
{ url: `${rootPath}/static/assets/hdri/wide-street.hdr` },
82+
{ mipmaps: false }
83+
)
84+
};
85+
86+
const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets);
87+
assetListLoader.load(() => {
88+
app.start();
89+
90+
let splatEntity = null;
91+
92+
// Create camera at startup so skydome is visible before dropping files
93+
const camera = new pc.Entity('camera');
94+
camera.addComponent('camera', {
95+
clearColor: new pc.Color(0.2, 0.2, 0.2),
96+
fov: 60,
97+
farClip: 1000
98+
});
99+
camera.setLocalPosition(0, 2, 5);
100+
app.root.addChild(camera);
101+
102+
// Setup CameraFrame
103+
const cameraFrame = new pc.CameraFrame(app, camera.camera);
104+
cameraFrame.rendering.renderFormats = [
105+
pc.PIXELFORMAT_RGBA16F,
106+
pc.PIXELFORMAT_RGBA32F,
107+
pc.PIXELFORMAT_111110F
108+
];
109+
cameraFrame.grading.enabled = true;
110+
111+
// Setup skydome toggle function
112+
const applySkydome = () => {
113+
const enabled = data.get('data.skydome');
114+
if (enabled) {
115+
const hdriTexture = assets.hdri.resource;
116+
117+
// Generate high resolution cubemap for skybox
118+
const skybox = pc.EnvLighting.generateSkyboxCubemap(hdriTexture);
119+
app.scene.skybox = skybox;
120+
121+
// Generate env-atlas for lighting
122+
const lighting = pc.EnvLighting.generateLightingSource(hdriTexture);
123+
const envAtlas = pc.EnvLighting.generateAtlas(lighting);
124+
lighting.destroy();
125+
app.scene.envAtlas = envAtlas;
126+
} else {
127+
app.scene.skybox = null;
128+
app.scene.envAtlas = null;
129+
}
130+
};
131+
132+
// Initialize data values
133+
data.set('data', {
134+
skydome: false,
135+
orientation: 180,
136+
tonemapping: pc.TONEMAP_LINEAR,
137+
grading: {
138+
exposure: 0, // 0 EV = no change
139+
contrast: 1
140+
}
141+
});
142+
143+
// Apply initial skydome setting
144+
applySkydome();
145+
146+
// Apply settings function
147+
const applySettings = () => {
148+
cameraFrame.rendering.toneMapping = data.get('data.tonemapping');
149+
150+
// Convert exposure EV (F-stops) to brightness multiplier
151+
// Each stop doubles or halves brightness: multiplier = 2^(EV)
152+
const exposureEV = data.get('data.grading.exposure');
153+
cameraFrame.grading.brightness = Math.pow(2, exposureEV);
154+
155+
cameraFrame.grading.contrast = data.get('data.grading.contrast');
156+
cameraFrame.update();
157+
};
158+
159+
// Apply initial settings
160+
applySettings();
161+
162+
// Listen for changes
163+
data.on('*:set', (/** @type {string} */ path) => {
164+
if (path === 'data.skydome') {
165+
applySkydome();
166+
} else if (path === 'data.orientation') {
167+
// Apply orientation to splat entity
168+
if (splatEntity) {
169+
const orientation = data.get('data.orientation');
170+
splatEntity.setLocalEulerAngles(orientation, 0, 0);
171+
}
172+
} else {
173+
applySettings();
174+
}
175+
});
176+
177+
// Setup drag and drop handlers
178+
canvas.addEventListener('dragover', (e) => {
179+
e.preventDefault();
180+
});
181+
182+
canvas.addEventListener('drop', async (e) => {
183+
e.preventDefault();
184+
185+
const file = e.dataTransfer.files[0];
186+
if (!file) return;
187+
188+
const fileName = file.name.toLowerCase();
189+
if (!fileName.endsWith('.ply') && !fileName.endsWith('.sog')) {
190+
console.warn('Please drop a .ply or .sog file');
191+
return;
192+
}
193+
194+
// Hide instructions overlay
195+
dropOverlay.style.display = 'none';
196+
197+
// Create blob URL and load asset using loadFromUrlAndFilename
198+
// This method is specifically for blob assets where the URL doesn't identify the format
199+
const blobUrl = URL.createObjectURL(file);
200+
201+
const asset = await new Promise((resolve, reject) => {
202+
app.assets.loadFromUrlAndFilename(blobUrl, file.name, 'gsplat', (err, loadedAsset) => {
203+
if (err) {
204+
reject(err);
205+
} else {
206+
resolve(loadedAsset);
207+
}
208+
});
209+
});
210+
211+
// Create gsplat entity
212+
const entity = new pc.Entity(file.name);
213+
entity.addComponent('gsplat', {
214+
asset: asset,
215+
unified: true
216+
});
217+
entity.setLocalEulerAngles(180, 0, 0);
218+
app.root.addChild(entity);
219+
220+
// Store reference for orientation updates
221+
splatEntity = entity;
222+
223+
// Wait a frame for customAabb to be available
224+
await new Promise((resolve) => {
225+
requestAnimationFrame(resolve);
226+
});
227+
228+
// Get bounds for framing
229+
const aabb = entity.gsplat.customAabb;
230+
if (!aabb) {
231+
console.warn('customAabb not available');
232+
return;
233+
}
234+
235+
const center = aabb.center;
236+
const size = aabb.halfExtents.length() * 2;
237+
const cameraDistance = size * 2.5;
238+
239+
// Update camera for the loaded splat
240+
camera.camera.farClip = size * 10;
241+
camera.setLocalPosition(
242+
center.x,
243+
center.y + size * 0.3,
244+
center.z + cameraDistance
245+
);
246+
247+
// Add orbit camera script
248+
camera.addComponent('script');
249+
camera.script.create('orbitCamera', {
250+
attributes: {
251+
inertiaFactor: 0.2,
252+
focusEntity: entity,
253+
distanceMax: size * 5,
254+
frameOnStart: true
255+
}
256+
});
257+
camera.script.create('orbitCameraInputMouse');
258+
camera.script.create('orbitCameraInputTouch');
259+
});
260+
});
261+
262+
export { app };
9.73 KB
Loading
1.69 KB
Loading

0 commit comments

Comments
 (0)