|
| 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 }; |
0 commit comments