Download src/main.js from coderofpears/stable-fast-3d-webgpu: direct link, hf CLI and curl.
- Browser
- Download file 26 kB
-
https://huggingface.co/spaces/coderofpears/stable-fast-3d-webgpu/resolve/main/src/main.js
- Command line
-
hf download hf://spaces/coderofpears/stable-fast-3d-webgpu/src/main.js
-
curl -L -o main.js https://huggingface.co/spaces/coderofpears/stable-fast-3d-webgpu/resolve/main/src/main.js
26 kB
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js'; | |
| import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1/+esm'; | |
| import * as ort from 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/webgpu/+esm'; | |
| const MODEL_ROOT = 'https://huggingface.co/needle-tools/SF3D-webgpu/resolve/main/'; | |
| const SAMPLE_URL = 'https://huggingface.co/spaces/stabilityai/stable-fast-3d/resolve/main/demo_files/examples/axe.png'; | |
| const INPUT_SIZE = 512; | |
| const GRID_RESOLUTION = 160; | |
| const ISO_THRESHOLD = 10; | |
| const TRIPLANE_CHANNELS = 40; | |
| const TRIPLANE_SIZE = 384; | |
| const DECODE_BATCH = 16384; | |
| const BACKGROUND_COLOR = 0.5; | |
| const el = (id) => document.getElementById(id); | |
| const ui = { | |
| file: el('image-file'), | |
| dropzone: el('dropzone'), | |
| previewWrap: el('preview-wrap'), | |
| preview: el('input-preview'), | |
| previewName: el('preview-name'), | |
| previewSize: el('preview-size'), | |
| sample: el('sample-button'), | |
| removeBackground: el('remove-background'), | |
| generate: el('generate-button'), | |
| generateLabel: el('generate-label'), | |
| webgpuDot: el('webgpu-dot'), | |
| webgpuLabel: el('webgpu-label'), | |
| stage: el('viewer-stage'), | |
| canvas: el('viewer-canvas'), | |
| empty: el('viewer-empty'), | |
| progress: el('viewer-progress'), | |
| progressPercent: el('progress-percent'), | |
| progressTitle: el('progress-title'), | |
| progressDetail: el('progress-detail'), | |
| error: el('viewer-error'), | |
| errorTitle: el('error-title'), | |
| errorDetail: el('error-detail'), | |
| errorDismiss: el('error-dismiss'), | |
| reset: el('reset-view'), | |
| stats: el('mesh-stats'), | |
| runtime: el('runtime-metric'), | |
| vertices: el('vertices-metric'), | |
| faces: el('faces-metric'), | |
| download: el('download-button'), | |
| }; | |
| let selectedFile = null; | |
| let selectedObjectUrl = null; | |
| let preparedCanvas = null; | |
| let backgroundPipeline = null; | |
| let sessions = null; | |
| let currentMeshObject = null; | |
| let currentMeshData = null; | |
| let webgpuReady = false; | |
| function setWebGPUStatus(kind, label) { | |
| ui.webgpuDot.className = `status-dot ${kind}`; | |
| ui.webgpuLabel.textContent = label; | |
| } | |
| function setProgress(title, detail, percent = null) { | |
| ui.progressTitle.textContent = title; | |
| ui.progressDetail.textContent = detail; | |
| if (percent === null) { | |
| ui.progressPercent.textContent = '…'; | |
| } else { | |
| ui.progressPercent.textContent = `${Math.round(percent)}%`; | |
| } | |
| } | |
| function showProgress(visible) { | |
| ui.progress.classList.toggle('is-hidden', !visible); | |
| ui.empty.classList.toggle('is-hidden', visible || Boolean(currentMeshObject)); | |
| } | |
| function showError(title, detail) { | |
| ui.errorTitle.textContent = title; | |
| ui.errorDetail.textContent = detail; | |
| ui.error.classList.remove('is-hidden'); | |
| showProgress(false); | |
| } | |
| function clearError() { | |
| ui.error.classList.add('is-hidden'); | |
| } | |
| function formatBytes(bytes) { | |
| if (!Number.isFinite(bytes) || bytes <= 0) return '—'; | |
| const units = ['B', 'KB', 'MB', 'GB']; | |
| const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); | |
| return `${(bytes / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`; | |
| } | |
| function formatDuration(ms) { | |
| return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`; | |
| } | |
| function isTransparentCanvas(canvas) { | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| for (let i = 3; i < data.length; i += 4) { | |
| if (data[i] < 250) return true; | |
| } | |
| return false; | |
| } | |
| function loadImageFromBlob(blob) { | |
| return new Promise((resolve, reject) => { | |
| const url = URL.createObjectURL(blob); | |
| const image = new Image(); | |
| image.onload = () => { | |
| URL.revokeObjectURL(url); | |
| resolve(image); | |
| }; | |
| image.onerror = () => { | |
| URL.revokeObjectURL(url); | |
| reject(new Error('The selected file could not be decoded as an image.')); | |
| }; | |
| image.src = url; | |
| }); | |
| } | |
| function imageToCanvas(image, maxSize = 1200) { | |
| const scale = Math.min(1, maxSize / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height)); | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale)); | |
| canvas.height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale)); | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.drawImage(image, 0, 0, canvas.width, canvas.height); | |
| return canvas; | |
| } | |
| function rawImageToCanvas(rawImage) { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = rawImage.width; | |
| canvas.height = rawImage.height; | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| const rgba = new Uint8ClampedArray(canvas.width * canvas.height * 4); | |
| const source = rawImage.data; | |
| const channels = rawImage.channels || 4; | |
| for (let i = 0, j = 0; i < source.length; i += channels, j += 4) { | |
| rgba[j] = source[i]; | |
| rgba[j + 1] = source[i + 1] ?? source[i]; | |
| rgba[j + 2] = source[i + 2] ?? source[i]; | |
| rgba[j + 3] = channels >= 4 ? source[i + 3] : 255; | |
| } | |
| ctx.putImageData(new ImageData(rgba, canvas.width, canvas.height), 0, 0); | |
| return canvas; | |
| } | |
| async function getBackgroundPipeline() { | |
| if (!backgroundPipeline) { | |
| setProgress('Loading cleanup model', 'Transformers.js is downloading MODNet for this browser.', null); | |
| backgroundPipeline = await pipeline('background-removal', 'Xenova/modnet', { | |
| device: 'webgpu', | |
| dtype: 'fp16', | |
| }); | |
| } | |
| return backgroundPipeline; | |
| } | |
| async function prepareSourceCanvas(file) { | |
| const image = await loadImageFromBlob(file); | |
| const original = imageToCanvas(image); | |
| if (!ui.removeBackground.checked || isTransparentCanvas(original)) { | |
| return original; | |
| } | |
| let blobUrl = null; | |
| try { | |
| const remover = await getBackgroundPipeline(); | |
| blobUrl = URL.createObjectURL(file); | |
| const output = await remover(blobUrl); | |
| const first = Array.isArray(output) ? output[0] : output; | |
| if (!first?.data || !first.width || !first.height) { | |
| throw new Error('The cleanup model returned an empty image.'); | |
| } | |
| return rawImageToCanvas(first); | |
| } catch (error) { | |
| console.warn('Background cleanup skipped:', error); | |
| setProgress('Using original image', 'Background cleanup was skipped; continuing with the uploaded pixels.', 5); | |
| return original; | |
| } finally { | |
| if (blobUrl) URL.revokeObjectURL(blobUrl); | |
| } | |
| } | |
| function alphaBounds(canvas) { | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| const { data, width, height } = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| let minX = width; | |
| let minY = height; | |
| let maxX = -1; | |
| let maxY = -1; | |
| for (let y = 0; y < height; y += 1) { | |
| for (let x = 0; x < width; x += 1) { | |
| if (data[(y * width + x) * 4 + 3] > 8) { | |
| minX = Math.min(minX, x); | |
| minY = Math.min(minY, y); | |
| maxX = Math.max(maxX, x); | |
| maxY = Math.max(maxY, y); | |
| } | |
| } | |
| } | |
| if (maxX < 0) return { x: 0, y: 0, width, height }; | |
| return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 }; | |
| } | |
| function prepareModelCanvas(source, ratio = 0.85) { | |
| const bounds = alphaBounds(source); | |
| const side = Math.max(bounds.width, bounds.height) / ratio; | |
| const centerX = bounds.x + bounds.width / 2; | |
| const centerY = bounds.y + bounds.height / 2; | |
| const cropX = centerX - side / 2; | |
| const cropY = centerY - side / 2; | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = INPUT_SIZE; | |
| canvas.height = INPUT_SIZE; | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| ctx.clearRect(0, 0, INPUT_SIZE, INPUT_SIZE); | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.imageSmoothingQuality = 'high'; | |
| ctx.drawImage(source, cropX, cropY, side, side, 0, 0, INPUT_SIZE, INPUT_SIZE); | |
| return canvas; | |
| } | |
| function renderInputPreview(canvas, name, bytes) { | |
| if (selectedObjectUrl) URL.revokeObjectURL(selectedObjectUrl); | |
| canvas.toBlob((blob) => { | |
| if (!blob) return; | |
| selectedObjectUrl = URL.createObjectURL(blob); | |
| ui.preview.src = selectedObjectUrl; | |
| }, 'image/png'); | |
| ui.previewName.textContent = name; | |
| ui.previewSize.textContent = formatBytes(bytes); | |
| ui.previewWrap.classList.remove('is-hidden'); | |
| } | |
| function selectFile(file) { | |
| if (!file || !file.type.startsWith('image/')) { | |
| showError('Unsupported file', 'Choose a PNG, JPEG, or WebP image.'); | |
| return; | |
| } | |
| selectedFile = file; | |
| clearError(); | |
| preparedCanvas = null; | |
| ui.generate.disabled = !webgpuReady; | |
| ui.generateLabel.textContent = webgpuReady ? 'Generate 3D mesh' : 'WebGPU required'; | |
| ui.previewWrap.classList.add('is-hidden'); | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| ui.preview.src = reader.result; | |
| ui.previewName.textContent = file.name; | |
| ui.previewSize.textContent = formatBytes(file.size); | |
| ui.previewWrap.classList.remove('is-hidden'); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| async function loadSample() { | |
| ui.sample.disabled = true; | |
| ui.sample.querySelector('span').textContent = 'Loading sample…'; | |
| try { | |
| const response = await fetch(SAMPLE_URL); | |
| if (!response.ok) throw new Error(`Sample request failed (${response.status}).`); | |
| const blob = await response.blob(); | |
| selectFile(new File([blob], 'axe.png', { type: blob.type || 'image/png' })); | |
| } catch (error) { | |
| showError('Sample unavailable', error.message); | |
| } finally { | |
| ui.sample.disabled = false; | |
| ui.sample.querySelector('span').textContent = 'Try a sample'; | |
| } | |
| } | |
| function urlFor(path) { | |
| return `${MODEL_ROOT}${path}?download=true`; | |
| } | |
| function resolveName(names, preferred, index = 0) { | |
| return preferred.find((candidate) => names.includes(candidate)) || names[index]; | |
| } | |
| function resolveOutput(outputs, preferred, index = 0) { | |
| for (const name of preferred) { | |
| if (outputs[name]) return outputs[name]; | |
| } | |
| const first = Object.keys(outputs)[index]; | |
| if (!first) throw new Error('The ONNX graph returned no output tensor.'); | |
| return outputs[first]; | |
| } | |
| async function createSessions() { | |
| if (sessions) return sessions; | |
| if (!webgpuReady) throw new Error('WebGPU is not available in this browser.'); | |
| if (ort.env?.wasm) { | |
| ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/'; | |
| ort.env.wasm.numThreads = 1; | |
| } | |
| const options = { | |
| executionProviders: ['webgpu'], | |
| graphOptimizationLevel: 'all', | |
| enableMemPattern: true, | |
| }; | |
| setProgress('Loading SF3D graphs', 'Downloading the tokenizer, backbone, and decoder. They will be cached after this run.', null); | |
| const [imageTokenizer, backbone, decoder] = await Promise.all([ | |
| ort.InferenceSession.create(urlFor('onnx/image_tokenizer_single.onnx'), options), | |
| ort.InferenceSession.create(urlFor('onnx/backbone_fp16.onnx'), options), | |
| ort.InferenceSession.create(urlFor('onnx/decoder_single.onnx'), options), | |
| ]); | |
| sessions = { imageTokenizer, backbone, decoder }; | |
| return sessions; | |
| } | |
| async function fetchBinary(path, label, progressOffset, progressScale) { | |
| const response = await fetch(urlFor(path)); | |
| if (!response.ok) throw new Error(`Could not load ${label} (${response.status}).`); | |
| const total = Number(response.headers.get('content-length')) || 0; | |
| const reader = response.body?.getReader(); | |
| if (!reader) return response.arrayBuffer(); | |
| const chunks = []; | |
| let loaded = 0; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| chunks.push(value); | |
| loaded += value.byteLength; | |
| if (total) setProgress('Loading mesh grid', `${label} · ${formatBytes(loaded)} / ${formatBytes(total)}`, progressOffset + (loaded / total) * progressScale); | |
| } | |
| const buffer = new ArrayBuffer(loaded); | |
| const output = new Uint8Array(buffer); | |
| let offset = 0; | |
| for (const chunk of chunks) { | |
| output.set(chunk, offset); | |
| offset += chunk.byteLength; | |
| } | |
| return buffer; | |
| } | |
| async function loadGrid() { | |
| setProgress('Loading mesh grid', 'Fetching the tetrahedral surface grid.', 68); | |
| const [verticesBuffer, indicesBuffer] = await Promise.all([ | |
| fetchBinary('tets_vertices.bin', 'vertices', 68, 7), | |
| fetchBinary('tets_indices.bin', 'tetrahedra', 75, 7), | |
| ]); | |
| return { | |
| vertices: new Float32Array(verticesBuffer), | |
| indices: new Uint32Array(indicesBuffer), | |
| }; | |
| } | |
| function createInputTensors(canvas, imageTokenizer) { | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| const { data } = ctx.getImageData(0, 0, INPUT_SIZE, INPUT_SIZE); | |
| const rgb = new Float32Array(INPUT_SIZE * INPUT_SIZE * 3); | |
| for (let i = 0, j = 0; i < data.length; i += 4, j += 3) { | |
| const alpha = data[i + 3] / 255; | |
| rgb[j] = BACKGROUND_COLOR * (1 - alpha) + (data[i] / 255) * alpha; | |
| rgb[j + 1] = BACKGROUND_COLOR * (1 - alpha) + (data[i + 1] / 255) * alpha; | |
| rgb[j + 2] = BACKGROUND_COLOR * (1 - alpha) + (data[i + 2] / 255) * alpha; | |
| } | |
| const inputNames = imageTokenizer.inputNames; | |
| const rgbName = resolveName(inputNames, ['rgb'], 0); | |
| const c2wName = resolveName(inputNames, ['c2w'], 1); | |
| const intrinsicName = resolveName(inputNames, ['intrinsic_normed'], 2); | |
| const focal = 1 / (2 * Math.tan((40 * Math.PI) / 360)); | |
| return { | |
| [rgbName]: new ort.Tensor('float32', rgb, [1, INPUT_SIZE, INPUT_SIZE, 3]), | |
| [c2wName]: new ort.Tensor('float32', new Float32Array([ | |
| 0, 0, 1, 1.6, | |
| 1, 0, 0, 0, | |
| 0, 1, 0, 0, | |
| 0, 0, 0, 1, | |
| ]), [1, 4, 4]), | |
| [intrinsicName]: new ort.Tensor('float32', new Float32Array([ | |
| focal, 0, 0.5, | |
| 0, focal, 0.5, | |
| 0, 0, 1, | |
| ]), [1, 3, 3]), | |
| }; | |
| } | |
| async function decodeGrid(grid, triplane, decoder) { | |
| const vertexCount = grid.vertices.length / 3; | |
| const density = new Float32Array(vertexCount); | |
| const deformation = new Float32Array(vertexCount * 3); | |
| const triplaneTensor = new ort.Tensor('float32', triplane, [1, 3, TRIPLANE_CHANNELS, TRIPLANE_SIZE, TRIPLANE_SIZE]); | |
| const positions = new Float32Array(DECODE_BATCH * 3); | |
| const inputNames = decoder.inputNames; | |
| const triplaneName = resolveName(inputNames, ['triplane'], 0); | |
| const positionsName = resolveName(inputNames, ['positions'], 1); | |
| const densityName = 'density'; | |
| const offsetName = 'vertex_offset'; | |
| for (let start = 0; start < vertexCount; start += DECODE_BATCH) { | |
| const count = Math.min(DECODE_BATCH, vertexCount - start); | |
| const currentPositions = positions.subarray(0, count * 3); | |
| for (let i = 0; i < count; i += 1) { | |
| const base = (start + i) * 3; | |
| currentPositions[i * 3] = grid.vertices[base] * 2 - 1; | |
| currentPositions[i * 3 + 1] = grid.vertices[base + 1] * 2 - 1; | |
| currentPositions[i * 3 + 2] = grid.vertices[base + 2] * 2 - 1; | |
| } | |
| const outputs = await decoder.run({ | |
| [triplaneName]: triplaneTensor, | |
| [positionsName]: new ort.Tensor('float32', currentPositions, [1, count, 3]), | |
| }); | |
| const densityTensor = resolveOutput(outputs, [densityName]); | |
| const offsetTensor = resolveOutput(outputs, [offsetName]); | |
| for (let i = 0; i < count; i += 1) { | |
| density[start + i] = densityTensor.data[i]; | |
| deformation[(start + i) * 3] = offsetTensor.data[i * 3]; | |
| deformation[(start + i) * 3 + 1] = offsetTensor.data[i * 3 + 1]; | |
| deformation[(start + i) * 3 + 2] = offsetTensor.data[i * 3 + 2]; | |
| } | |
| const percent = 82 + ((start + count) / vertexCount) * 15; | |
| setProgress('Decoding surface', `${(start + count).toLocaleString()} / ${vertexCount.toLocaleString()} grid points`, percent); | |
| } | |
| return { density, deformation }; | |
| } | |
| const TRIANGLE_TABLE = [ | |
| [-1, -1, -1, -1, -1, -1], [1, 0, 2, -1, -1, -1], [4, 0, 3, -1, -1, -1], [1, 4, 2, 1, 3, 4], | |
| [3, 1, 5, -1, -1, -1], [2, 3, 0, 2, 5, 3], [1, 4, 0, 1, 5, 4], [4, 2, 5, -1, -1, -1], | |
| [4, 5, 2, -1, -1, -1], [4, 1, 0, 4, 5, 1], [3, 2, 0, 3, 5, 2], [1, 3, 5, -1, -1, -1], | |
| [4, 1, 2, 4, 3, 1], [3, 0, 4, -1, -1, -1], [2, 0, 1, -1, -1, -1], [-1, -1, -1, -1, -1, -1], | |
| ]; | |
| function buildMesh(grid, decoded) { | |
| const vertexCount = grid.vertices.length / 3; | |
| const tetCount = grid.indices.length / 4; | |
| const occupied = new Uint8Array(vertexCount); | |
| for (let i = 0; i < vertexCount; i += 1) occupied[i] = decoded.density[i] > ISO_THRESHOLD ? 1 : 0; | |
| const positions = []; | |
| const faces = []; | |
| const edgeMap = new Map(); | |
| const deformed = (index, axis) => { | |
| const value = grid.vertices[index * 3 + axis] + (2 / GRID_RESOLUTION) * Math.tanh(decoded.deformation[index * 3 + axis]); | |
| return value * 2 - 1; | |
| }; | |
| const edgeVertex = (a, b) => { | |
| if (occupied[a] === occupied[b]) return -1; | |
| const low = Math.min(a, b); | |
| const high = Math.max(a, b); | |
| const key = low * vertexCount + high; | |
| const existing = edgeMap.get(key); | |
| if (existing !== undefined) return existing; | |
| const s0 = decoded.density[a] - ISO_THRESHOLD; | |
| const s1 = decoded.density[b] - ISO_THRESHOLD; | |
| const denominator = s0 - s1 || 1e-6; | |
| const t = Math.max(0, Math.min(1, s0 / denominator)); | |
| const x = deformed(a, 0) * (1 - t) + deformed(b, 0) * t; | |
| const y = deformed(a, 1) * (1 - t) + deformed(b, 1) * t; | |
| const z = deformed(a, 2) * (1 - t) + deformed(b, 2) * t; | |
| const index = positions.length / 3; | |
| positions.push(x, y, z); | |
| edgeMap.set(key, index); | |
| return index; | |
| }; | |
| for (let tet = 0; tet < tetCount; tet += 1) { | |
| const offset = tet * 4; | |
| const c0 = grid.indices[offset]; | |
| const c1 = grid.indices[offset + 1]; | |
| const c2 = grid.indices[offset + 2]; | |
| const c3 = grid.indices[offset + 3]; | |
| const mask = occupied[c0] | (occupied[c1] << 1) | (occupied[c2] << 2) | (occupied[c3] << 3); | |
| if (mask === 0 || mask === 15) continue; | |
| const edgeIndices = new Int32Array(6); | |
| edgeIndices[0] = edgeVertex(c0, c1); | |
| edgeIndices[1] = edgeVertex(c0, c2); | |
| edgeIndices[2] = edgeVertex(c0, c3); | |
| edgeIndices[3] = edgeVertex(c1, c2); | |
| edgeIndices[4] = edgeVertex(c1, c3); | |
| edgeIndices[5] = edgeVertex(c2, c3); | |
| const table = TRIANGLE_TABLE[mask]; | |
| for (let i = 0; i < 6 && table[i] !== -1; i += 3) { | |
| faces.push(edgeIndices[table[i]], edgeIndices[table[i + 1]], edgeIndices[table[i + 2]]); | |
| } | |
| } | |
| if (!faces.length) throw new Error('No surface was found. Try a clearer image with one centered object.'); | |
| return { positions: new Float32Array(positions), faces: new Uint32Array(faces) }; | |
| } | |
| function initViewer() { | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 100); | |
| camera.position.set(0, 0.15, 3.4); | |
| const renderer = new THREE.WebGLRenderer({ canvas: ui.canvas, antialias: true, alpha: true }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.15; | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.07; | |
| controls.minDistance = 1.2; | |
| controls.maxDistance = 7; | |
| controls.target.set(0, 0, 0); | |
| scene.add(new THREE.HemisphereLight(0xdcd7ff, 0x1f2330, 2.1)); | |
| const keyLight = new THREE.DirectionalLight(0xffffff, 3.5); | |
| keyLight.position.set(3, 4, 4); | |
| scene.add(keyLight); | |
| const rimLight = new THREE.DirectionalLight(0xc7baff, 2.2); | |
| rimLight.position.set(-4, 2, -3); | |
| scene.add(rimLight); | |
| const resize = () => { | |
| const width = ui.stage.clientWidth; | |
| const height = ui.stage.clientHeight; | |
| renderer.setSize(width, height, false); | |
| camera.aspect = width / Math.max(height, 1); | |
| camera.updateProjectionMatrix(); | |
| }; | |
| const reset = () => { | |
| camera.position.set(0, 0.15, 3.4); | |
| controls.target.set(0, 0, 0); | |
| controls.update(); | |
| }; | |
| window.addEventListener('resize', resize); | |
| ui.reset.addEventListener('click', reset); | |
| resize(); | |
| const animate = () => { | |
| requestAnimationFrame(animate); | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| }; | |
| animate(); | |
| return { | |
| scene, | |
| camera, | |
| reset, | |
| setMesh(meshData) { | |
| if (currentMeshObject) { | |
| scene.remove(currentMeshObject); | |
| currentMeshObject.geometry.dispose(); | |
| currentMeshObject.material.dispose(); | |
| } | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(meshData.positions, 3)); | |
| geometry.setIndex(new THREE.BufferAttribute(meshData.faces, 1)); | |
| geometry.computeVertexNormals(); | |
| geometry.computeBoundingBox(); | |
| const center = geometry.boundingBox.getCenter(new THREE.Vector3()); | |
| const size = geometry.boundingBox.getSize(new THREE.Vector3()); | |
| const maxDimension = Math.max(size.x, size.y, size.z) || 1; | |
| geometry.translate(-center.x, -center.y, -center.z); | |
| geometry.scale(2.15 / maxDimension, 2.15 / maxDimension, 2.15 / maxDimension); | |
| const material = new THREE.MeshStandardMaterial({ color: 0xc9b9ff, roughness: 0.44, metalness: 0.14, side: THREE.DoubleSide }); | |
| currentMeshObject = new THREE.Mesh(geometry, material); | |
| scene.add(currentMeshObject); | |
| reset(); | |
| ui.empty.classList.add('is-hidden'); | |
| ui.stats.textContent = `${meshData.faces.length / 3} triangles · drag to orbit`; | |
| ui.download.disabled = false; | |
| }, | |
| }; | |
| } | |
| const viewer = initViewer(); | |
| async function runGeneration() { | |
| if (!selectedFile || !webgpuReady) return; | |
| const started = performance.now(); | |
| ui.generate.disabled = true; | |
| ui.download.disabled = true; | |
| clearError(); | |
| showProgress(true); | |
| try { | |
| setProgress('Preparing image', 'Composing the subject over SF3D’s neutral background.', 5); | |
| const sourceCanvas = await prepareSourceCanvas(selectedFile); | |
| preparedCanvas = prepareModelCanvas(sourceCanvas); | |
| renderInputPreview(preparedCanvas, `${selectedFile.name} · prepared`, selectedFile.size); | |
| setProgress('Loading models', 'Connecting to the browser-compatible SF3D export.', 15); | |
| const [modelSessions, grid] = await Promise.all([createSessions(), loadGrid()]); | |
| setProgress('Encoding image', 'The image tokenizer is running on WebGPU.', 78); | |
| const tokenizerInputs = createInputTensors(preparedCanvas, modelSessions.imageTokenizer); | |
| const tokenizerOutputs = await modelSessions.imageTokenizer.run(tokenizerInputs); | |
| const imageTokens = resolveOutput(tokenizerOutputs, ['image_tokens']); | |
| setProgress('Reconstructing shape', 'The SF3D backbone is predicting a 3D triplane.', 80); | |
| const backboneInputName = resolveName(modelSessions.backbone.inputNames, ['image_tokens'], 0); | |
| const backboneOutputs = await modelSessions.backbone.run({ [backboneInputName]: imageTokens }); | |
| const triplane = resolveOutput(backboneOutputs, ['triplane']).data; | |
| setProgress('Decoding surface', 'Sampling the triplane and extracting the tetrahedral surface.', 82); | |
| const decoded = await decodeGrid(grid, triplane, modelSessions.decoder); | |
| const meshData = buildMesh(grid, decoded); | |
| currentMeshData = meshData; | |
| viewer.setMesh(meshData); | |
| const elapsed = performance.now() - started; | |
| ui.runtime.textContent = formatDuration(elapsed); | |
| ui.vertices.textContent = (meshData.positions.length / 3).toLocaleString(); | |
| ui.faces.textContent = (meshData.faces.length / 3).toLocaleString(); | |
| setProgress('Done', 'Your mesh is ready.', 100); | |
| showProgress(false); | |
| } catch (error) { | |
| console.error(error); | |
| showError('Generation failed', error?.message || 'The browser could not complete WebGPU inference.'); | |
| ui.stats.textContent = 'No mesh loaded'; | |
| } finally { | |
| ui.generate.disabled = false; | |
| ui.generateLabel.textContent = 'Generate 3D mesh'; | |
| } | |
| } | |
| async function downloadGLB() { | |
| if (!currentMeshObject) return; | |
| ui.download.disabled = true; | |
| try { | |
| const exporter = new GLTFExporter(); | |
| const result = await new Promise((resolve, reject) => exporter.parse(currentMeshObject, resolve, reject, { binary: true })); | |
| const blob = new Blob([result], { type: 'model/gltf-binary' }); | |
| const url = URL.createObjectURL(blob); | |
| const anchor = document.createElement('a'); | |
| anchor.href = url; | |
| anchor.download = 'stable-fast-3d-mesh.glb'; | |
| anchor.click(); | |
| setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| } catch (error) { | |
| showError('Download failed', error.message || 'The GLB exporter could not finish.'); | |
| } finally { | |
| ui.download.disabled = false; | |
| } | |
| } | |
| async function checkWebGPU() { | |
| if (!navigator.gpu) { | |
| setWebGPUStatus('error', 'WebGPU unavailable'); | |
| ui.generateLabel.textContent = 'WebGPU required'; | |
| return; | |
| } | |
| try { | |
| const adapter = await navigator.gpu.requestAdapter(); | |
| if (!adapter) throw new Error('No compatible GPU adapter found.'); | |
| webgpuReady = true; | |
| setWebGPUStatus('ready', 'WebGPU ready'); | |
| ui.generate.disabled = !selectedFile; | |
| ui.generateLabel.textContent = selectedFile ? 'Generate 3D mesh' : 'Select an image'; | |
| } catch (error) { | |
| setWebGPUStatus('error', 'WebGPU unavailable'); | |
| showError('WebGPU is required', error.message || 'Use a recent Chrome or Edge browser with WebGPU enabled.'); | |
| } | |
| } | |
| ui.file.addEventListener('change', (event) => selectFile(event.target.files?.[0])); | |
| ui.sample.addEventListener('click', loadSample); | |
| ui.generate.addEventListener('click', runGeneration); | |
| ui.download.addEventListener('click', downloadGLB); | |
| ui.errorDismiss.addEventListener('click', clearError); | |
| ui.dropzone.addEventListener('dragover', (event) => { event.preventDefault(); ui.dropzone.classList.add('dragging'); }); | |
| ui.dropzone.addEventListener('dragleave', () => ui.dropzone.classList.remove('dragging')); | |
| ui.dropzone.addEventListener('drop', (event) => { | |
| event.preventDefault(); | |
| ui.dropzone.classList.remove('dragging'); | |
| selectFile(event.dataTransfer.files?.[0]); | |
| }); | |
| window.addEventListener('pagehide', () => { | |
| if (backgroundPipeline?.dispose) backgroundPipeline.dispose(); | |
| if (sessions) Object.values(sessions).forEach((session) => session.release?.()); | |
| }); | |
| checkWebGPU(); | |