Download vortex.js from Xenova/navier-stokes-vortex-webgpu: direct link, hf CLI and curl.
- Browser
- Download file 22 kB
-
https://huggingface.co/spaces/Xenova/navier-stokes-vortex-webgpu/resolve/main/vortex.js
- Command line
-
hf download hf://spaces/Xenova/navier-stokes-vortex-webgpu/vortex.js
-
curl -L -o vortex.js https://huggingface.co/spaces/Xenova/navier-stokes-vortex-webgpu/resolve/main/vortex.js
22 kB
| // Native WebGPU renderer of numerically solved leading-core profiles. | |
| // Higher-order background, a matched exterior, oscillatory pulses and the | |
| // smooth-force completion are not rendered. See the Model & sources dialog. | |
| import { SEGMENTS, POINTS, SIDES, THREAD_COUNT, MARKERS_PER_THREAD, STORY_TRAIL_SEGMENTS } from './render-config.mjs'; | |
| import { VIEW_UNIFORM_FLOATS, packViewUniforms } from './render-uniforms.mjs'; | |
| import { createRenderTargets } from './render-targets.mjs'; | |
| import { createAnnotationRenderer } from './annotation-renderer.mjs'; | |
| import { createAnnotations } from './annotations.mjs'; | |
| import { makeStoryCamera } from './camera.mjs'; | |
| import { createPipelines } from './gpu-pipelines.mjs'; | |
| import { DEFAULT_H, logScales } from './proof-model.mjs'; | |
| import { PROFILE_FLOATS, loadCoreProfile } from './core-profile.mjs'; | |
| import { paletteGradient } from './color-scale.mjs'; | |
| import { setProfileEnvelope } from './presentation.mjs'; | |
| import { STORY, STORY_VIEW_SECONDS, CHAPTERS, STORY_VIEWS, storyDiagramCue, storyEditOpacity, storyEnding, storyAt, | |
| advanceStory, storyPanels, storySceneLayout, storyClock, storyPose, storyFocus, interactiveStoryPose } from './story.mjs'; | |
| import { STORY_AXIS_MESH } from './story-axis-mesh.mjs'; | |
| const $ = id => document.getElementById(id); | |
| const canvas = $('view'); | |
| // ?record freezes the story clock for an offline, frame-exact recording; | |
| // tools/record.mjs sets the time per frame and captures the composited page. | |
| const RECORD = new URLSearchParams(location.search).has('record'); | |
| const state = { | |
| storySeconds: 0, // presentation time in seconds, 0 … STORY.duration | |
| storyChoice: null, // a reader-selected view, or null while the film directs | |
| playing: false, | |
| time: 0, // orders of magnitude of remaining time, from storySeconds | |
| clock: 0, // slowed display clock shared by ribbons and flow markers | |
| }; | |
| let device, context, frameId, resizeObserver, destroyed = false, renderedFrames = 0; | |
| let streamlinesDirty = true, renderViews = []; | |
| let storyChapter = -1, storyCaptionKey = ''; | |
| let storyPoseCurrent = storyPose(0), storyManual = null; | |
| const annotations = createAnnotations(document); | |
| const reducedMotion = () => !!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches; | |
| const resources = []; | |
| function updateRange(input){ | |
| input.style.setProperty('--fill',`${100*(Number(input.value)-Number(input.min))/(Number(input.max)-Number(input.min))}%`); | |
| } | |
| const stamp = s => `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`; | |
| function updateStory(){ | |
| const scene=storyAt(state.storySeconds); | |
| const editOpacity=state.playing&&!reducedMotion()?storyEditOpacity(state.storySeconds,[0,36,54]):1; | |
| const ending=storyEnding(state.storyChoice===null?state.storySeconds:0).background; | |
| $('story-copy').style.opacity=String(1-ending*.72); | |
| $('story-equation').style.opacity=String(editOpacity*(1-ending*.72)); | |
| canvas.style.opacity=String(1-ending*.84); | |
| $('story-panels').style.opacity=String(1-ending*.82); | |
| document.querySelector('.story-legends').style.opacity=String(1-ending*.72); | |
| if(storyChapter!==scene.chapter){ | |
| storyChapter=scene.chapter;state.storyChoice=null; | |
| document.querySelectorAll('[data-chapter]').forEach(button=>{ | |
| if(Number(button.dataset.chapter)===scene.chapter)button.setAttribute('aria-current','step'); | |
| else button.removeAttribute('aria-current'); | |
| }); | |
| for(const name of ['time','aspect','energy'])$(`story-formula-${name}`).hidden=scene.formula!==name; | |
| } | |
| state.time=scene.decades; | |
| state.clock=storyClock(state.storySeconds); | |
| const scales=logScales(scene.decades); | |
| const exponent=n=>Math.abs(n)<.005?'0.00':n.toFixed(2).replace('-','−'); | |
| $('story-tau-exponent').textContent=exponent(-scene.decades); | |
| $('story-aspect-value').textContent=(10**scales.aspect).toFixed(3); | |
| $('story-speed-exponent').textContent=exponent(scales.speed); | |
| $('story-radius-exponent').textContent=exponent(scales.radial); | |
| $('story-progress').value=state.storySeconds;updateRange($('story-progress')); | |
| $('story-progress').setAttribute('aria-valuetext',`${scene.name}; time remaining is approximately 10 to the power minus ${scene.decades.toFixed(2)} of its reference value; ${Math.floor(state.storySeconds)} of ${STORY.duration} presentation seconds`); | |
| $('story-elapsed').textContent=`${stamp(state.storySeconds)} / ${stamp(STORY.duration)}`; | |
| const selected=state.storyChoice??scene.focus; | |
| document.querySelectorAll('[data-story-view]').forEach(button=> | |
| button.setAttribute('aria-pressed',String(Number(button.dataset.storyView)===selected))); | |
| } | |
| function updatePlay(){ | |
| const label=state.playing?'Pause story' | |
| :state.storySeconds>=STORY.duration?'Replay story' | |
| :state.storySeconds===0?'Play the story':'Continue story'; | |
| const play=$('play'); | |
| play.innerHTML=state.playing | |
| ?'<svg viewBox="0 0 24 24"><path d="M8 5v14M16 5v14" stroke-width="2.5"/></svg>' | |
| :'<svg viewBox="0 0 24 24"><path d="m9 5 10 7-10 7Z" fill="currentColor" stroke="none"/></svg>'; | |
| play.setAttribute('aria-label',label); | |
| play.title=`${label} (Space)`; | |
| } | |
| // Offline recording: every frame is a pure function of story time, so the | |
| // driver sets that time and waits until three further frames have been | |
| // submitted, by which point the compositor holds the new picture. | |
| if(RECORD)window.vortexFrame=seconds=>{ | |
| state.storySeconds=Math.max(0,Math.min(STORY.duration,seconds)); | |
| state.playing=state.storySeconds<STORY.duration;storyManual=null; | |
| updatePlay();updateStory(); | |
| const target=renderedFrames+3; | |
| return new Promise(resolve=>{const check=()=>renderedFrames>=target?resolve(target):requestAnimationFrame(check);check();}); | |
| }; | |
| function togglePlay(){ | |
| if(!state.playing&&state.storySeconds>=STORY.duration){ | |
| state.storySeconds=0;storyChapter=-1;storyManual=null; | |
| } | |
| if(!state.playing&&storyManual){ | |
| // Resume from the reader's camera, then rejoin the film's choreography. | |
| state.storyChoice=null; | |
| storyManual={from:{...storyPoseCurrent},to:storyPose(state.storySeconds),elapsed:0,follow:true}; | |
| } | |
| state.playing=!state.playing;updatePlay();updateStory(); | |
| } | |
| function jumpChapter(index){ | |
| const from={...storyPoseCurrent}; | |
| state.storySeconds=CHAPTERS[Math.max(0,Math.min(CHAPTERS.length-1,index))].start; | |
| state.storyChoice=null;storyManual=null;storyChapter=-1; | |
| updateStory();updatePlay(); | |
| storyManual={from,to:storyPose(state.storySeconds),elapsed:0,follow:true}; | |
| } | |
| function bindControls(){ | |
| $('story-legend-gradient').style.background=paletteGradient(); | |
| updateRange($('story-progress')); | |
| document.querySelectorAll('[data-chapter]').forEach(button=> | |
| button.addEventListener('click',()=>jumpChapter(Number(button.dataset.chapter)))); | |
| document.querySelectorAll('[data-story-view]').forEach(button=>button.addEventListener('click',()=>{ | |
| const choice=Number(button.dataset.storyView),target=storyFocus(choice); | |
| if(state.storyChoice===choice&&storyManual&&!storyManual.follow&&Object.keys(target).every(k=>storyManual.to[k]===target[k]))return; | |
| state.storyChoice=choice; | |
| storyManual={from:{...storyPoseCurrent},to:target,elapsed:0}; | |
| state.playing=false;updatePlay();updateStory(); | |
| })); | |
| $('story-progress').addEventListener('input',()=>{ | |
| state.storySeconds=Number($('story-progress').value); | |
| state.storyChoice=null;storyManual=null;state.playing=false; | |
| updateStory();updatePlay(); | |
| }); | |
| $('play').addEventListener('click',togglePlay); | |
| $('restart').addEventListener('click',()=>{ | |
| state.storySeconds=0;state.storyChoice=null;storyManual=null;storyChapter=-1; | |
| state.playing=true;updateStory();updatePlay(); | |
| }); | |
| document.querySelector('.about-link').addEventListener('click',()=>$('about').showModal()); | |
| $('close-about').addEventListener('click',()=>$('about').close()); | |
| $('about').addEventListener('click',event=>{ | |
| // Only a click on the backdrop, outside the dialog box itself, closes it. | |
| if(event.target!==$('about'))return; | |
| const box=$('about').getBoundingClientRect(); | |
| if(event.clientX<box.left||event.clientX>box.right||event.clientY<box.top||event.clientY>box.bottom)$('about').close(); | |
| }); | |
| $('retry').addEventListener('click',()=>location.reload()); | |
| document.addEventListener('keydown',event=>{ | |
| if($('about').open)return; | |
| // Space toggles playback unless it belongs to the focused control: the | |
| // scrubber, or a transport, chapter or view button. | |
| const activeTag=document.activeElement?.tagName||''; | |
| if(event.code==='Space'&&activeTag!=='INPUT'&&activeTag!=='BUTTON'){event.preventDefault();togglePlay();} | |
| }); | |
| updatePlay();updateStory(); | |
| } | |
| const pointers=new Map(); | |
| function bindCamera(){ | |
| canvas.addEventListener('pointerdown',event=>{ | |
| const main=renderViews.find(view=>view.featured); | |
| if(!main)return; | |
| const r=main.rect; | |
| if(event.clientX<r.x||event.clientX>r.x+r.width||event.clientY<r.y||event.clientY>r.y+r.height)return; | |
| pointers.set(event.pointerId,[event.clientX,event.clientY]); | |
| canvas.setPointerCapture(event.pointerId);canvas.classList.add('dragging'); | |
| }); | |
| canvas.addEventListener('pointermove',event=>{ | |
| const previous=pointers.get(event.pointerId); | |
| if(!previous||pointers.size!==1)return; | |
| pointers.set(event.pointerId,[event.clientX,event.clientY]); | |
| const pose={...storyPoseCurrent, | |
| yaw:storyPoseCurrent.yaw-(event.clientX-previous[0])*.005, | |
| pitch:Math.max(-1.53,Math.min(1.57,storyPoseCurrent.pitch+(event.clientY-previous[1])*.005))}; | |
| pose.fitPitch=pose.pitch; | |
| storyManual={from:pose,to:pose,elapsed:STORY_VIEW_SECONDS}; | |
| }); | |
| const release=event=>{ | |
| pointers.delete(event.pointerId); | |
| if(!pointers.size)canvas.classList.remove('dragging'); | |
| }; | |
| canvas.addEventListener('pointerup',release); | |
| canvas.addEventListener('pointercancel',release); | |
| canvas.addEventListener('lostpointercapture',release); | |
| // Each view keeps the bounds the story gives it; swallow page zoom gestures. | |
| canvas.addEventListener('wheel',event=>event.preventDefault(),{passive:false}); | |
| } | |
| // Viewports never exchange positions. Only the main camera and its slab / | |
| // ribbon emphasis change; the three linked reference cameras remain in place. | |
| function prepareViews(){ | |
| const width=canvas.clientWidth,height=canvas.clientHeight; | |
| const scene=storyAt(state.storySeconds),selected=state.storyChoice??scene.focus; | |
| const panels=storyPanels(width,height); | |
| if(storyManual?.follow&&storyManual.elapsed>=STORY_VIEW_SECONDS)storyManual=null; | |
| storyPoseCurrent=storyManual | |
| ?interactiveStoryPose(storyManual.from,storyManual.follow?storyPose(state.storySeconds):storyManual.to,storyManual.elapsed/STORY_VIEW_SECONDS) | |
| :storyPose(state.storySeconds); | |
| const narration=storyDiagramCue(state.storySeconds,state.storyChoice); | |
| if(narration.key!==storyCaptionKey){ | |
| storyCaptionKey=narration.key; | |
| $('story-description').textContent=narration.text; | |
| $('story-diagram-title').textContent=narration.title; | |
| } | |
| const views=[]; | |
| for(const panel of panels){ | |
| const element=$(`story-panel-${panel.id}`);element.hidden=!panel.visible; | |
| if(!panel.visible)continue; | |
| const name=STORY_VIEWS[panel.featured?selected:panel.id]; | |
| element.dataset.featured=String(panel.featured); | |
| element.style.left=`${panel.x}px`;element.style.top=`${panel.y}px`; | |
| element.style.width=`${panel.width}px`;element.style.height=`${panel.height}px`; | |
| const button=element.querySelector('button'); | |
| button.querySelector('b').textContent=name; | |
| button.setAttribute('aria-label',`${panel.featured?'Main camera':'Focus'}: ${name}`); | |
| let rect={...panel.plot}; | |
| if(panel.featured){ | |
| let layout=storySceneLayout(panel,width<760);rect=layout.rect; | |
| const equation=$('story-equation'),copy=$('story-copy'); | |
| equation.dataset.placement=layout.wide?'beside':'below'; | |
| copy.dataset.placement=layout.wide?'beside':'above'; | |
| copy.style.width=`${layout.copy.width}px`; | |
| // Measure the reserved text slot after setting its responsive width. | |
| // Native text enlargement then also moves the equation out of its way. | |
| if(copy.offsetHeight){layout=storySceneLayout(panel,width<760,copy.offsetHeight);rect=layout.rect;} | |
| for(const [element,box] of [[copy,layout.copy],[equation,layout.equation]]){ | |
| element.style.left=`${box.x}px`;element.style.top=`${box.y}px`;element.style.width=`${box.width}px`; | |
| } | |
| } | |
| const pose=panel.featured?storyPoseCurrent:storyFocus(panel.id); | |
| const view=makeStoryCamera(rect,pose,state.time,panel.featured); | |
| view.featured=panel.featured;views.push(view); | |
| } | |
| return views; | |
| } | |
| function fail(error) { | |
| state.playing=false; | |
| updatePlay(); | |
| cancelAnimationFrame(frameId); | |
| const panel=$('loading'); | |
| panel.hidden=false; | |
| panel.querySelector('.spinner').hidden=true; | |
| panel.querySelector('strong').textContent='The vortex could not start'; | |
| panel.querySelector('p').textContent=error.message||String(error); | |
| $('retry').hidden=false; | |
| console.error(error); | |
| } | |
| async function initialize() { | |
| if (!navigator.gpu) throw new Error('WebGPU is unavailable in this browser. Open this page in a browser with WebGPU enabled.'); | |
| const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' }); | |
| if (!adapter) throw new Error('No WebGPU adapter was found. Check that hardware acceleration is enabled in your browser.'); | |
| const [gpu, profileTable] = await Promise.all([adapter.requestDevice(), loadCoreProfile()]); | |
| device = gpu; | |
| setProfileEnvelope(profileTable,DEFAULT_H); | |
| device.lost.then((info) => { if (!destroyed) fail(new Error(`The GPU connection was lost. ${info.message || 'Reload to reconnect.'}`)); }); | |
| device.addEventListener('uncapturederror', (event) => fail(event.error)); | |
| context = canvas.getContext('webgpu'); | |
| if (!context) throw new Error('A WebGPU drawing surface could not be created.'); | |
| const format = navigator.gpu.getPreferredCanvasFormat(); | |
| context.configure({ device, format, alphaMode: 'opaque' }); | |
| const uniformData = new Float32Array(VIEW_UNIFORM_FLOATS); | |
| // Separate buffers are essential: queue writes must not make all viewports use the final camera. | |
| const uniformBuffers=Array.from({length:4},(_,i)=>device.createBuffer({size:uniformData.byteLength,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST,label:`Vortex uniforms ${i}`})); | |
| const pointBuffer = device.createBuffer({ size: THREAD_COUNT*POINTS*32, usage: GPUBufferUsage.STORAGE, label: 'Numerical core streamlines and local rotation' }); | |
| const indices=new Uint16Array(SEGMENTS*SIDES*6); | |
| for(let i=0,offset=0;i<SEGMENTS;i++)for(let side=0;side<SIDES;side++){ | |
| const a=i*SIDES+side,b=i*SIDES+(side+1)%SIDES; | |
| indices.set([a,b,a+SIDES,b,b+SIDES,a+SIDES],offset);offset+=6; | |
| } | |
| const indexBuffer=device.createBuffer({size:indices.byteLength,usage:GPUBufferUsage.INDEX|GPUBufferUsage.COPY_DST,label:'Tube topology'}); | |
| device.queue.writeBuffer(indexBuffer,0,indices); | |
| const indexCount=indices.length; | |
| const postData=new Float32Array(20); | |
| const postBuffer=device.createBuffer({size:80,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST,label:'Postprocessing uniforms'}); | |
| const profileBuffer=device.createBuffer({size:PROFILE_FLOATS*4,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST,label:'Numerical core profile data'}); | |
| device.queue.writeBuffer(profileBuffer,0,profileTable); | |
| const axisGlyphBuffer=device.createBuffer({size:STORY_AXIS_MESH.byteLength,usage:GPUBufferUsage.VERTEX|GPUBufferUsage.COPY_DST,label:'Latin Modern Math axis glyphs'}); | |
| device.queue.writeBuffer(axisGlyphBuffer,0,STORY_AXIS_MESH); | |
| resources.push(...uniformBuffers,pointBuffer,indexBuffer,profileBuffer,postBuffer,axisGlyphBuffer); | |
| const sampler = device.createSampler({ minFilter:'linear', magFilter:'linear', addressModeU:'clamp-to-edge', addressModeV:'clamp-to-edge' }); | |
| const { geometryLayout, compute, tubes, axis, background, horizontal, vertical, composite, resolve, wideHorizontal, wideVertical, storyParticles, storyAxisGlyphs, storyParticleDepth } = await createPipelines(device, format); | |
| const annotationRenderer=await createAnnotationRenderer(device,geometryLayout); | |
| resources.push(annotationRenderer); | |
| // The compute pass reads only h and the streamline count, which every | |
| // viewport's buffer carries identically; any one of them will do. | |
| const computeGroup = device.createBindGroup({ layout:compute.getBindGroupLayout(0), entries:[{binding:0,resource:{buffer:uniformBuffers[0]}},{binding:1,resource:{buffer:pointBuffer}},{binding:2,resource:{buffer:profileBuffer}}] }); | |
| const geometryGroups=uniformBuffers.map(buffer=>device.createBindGroup({layout:geometryLayout,entries:[{binding:0,resource:{buffer}},{binding:1,resource:{buffer:pointBuffer}},{binding:2,resource:{buffer:profileBuffer}}]})); | |
| const targets=createRenderTargets(device,canvas,sampler,postBuffer,{resolve,horizontal,vertical,wideHorizontal,wideVertical,composite}); | |
| resources.push(targets); | |
| let pendingResize=true; | |
| resizeObserver=new ResizeObserver(()=>{pendingResize=true;}); | |
| resizeObserver.observe(canvas); | |
| let last=0; | |
| function fullscreenPass(encoder, target, pipeline, group) { | |
| const pass = encoder.beginRenderPass({ colorAttachments:[{view:target,clearValue:{r:0,g:0,b:0,a:1},loadOp:'clear',storeOp:'store'}] }); | |
| pass.setPipeline(pipeline); | |
| pass.setBindGroup(0,group); | |
| pass.draw(3); | |
| pass.end(); | |
| } | |
| function frame(now) { | |
| if (destroyed) return; | |
| const dt = last ? Math.min((now-last)/1000,0.05) : 0; | |
| last=now; | |
| if (document.hidden) { frameId=requestAnimationFrame(frame); return; } | |
| if (pendingResize) { targets.resize(devicePixelRatio,RECORD); pendingResize=false; } | |
| if (state.playing && !$('about').open && !RECORD) { | |
| state.storySeconds=advanceStory(state.storySeconds,dt); | |
| updateStory(); | |
| if(state.storySeconds>=STORY.duration){state.playing=false;updatePlay();} | |
| } | |
| if(storyManual)storyManual.elapsed=Math.min(STORY_VIEW_SECONDS,storyManual.elapsed+(reducedMotion()?STORY_VIEW_SECONDS:dt)); | |
| renderViews=prepareViews(); | |
| annotationRenderer.update(annotations.update(state,renderViews)); | |
| for(let i=0;i<renderViews.length;i++){ | |
| packViewUniforms(uniformData,renderViews[i],state.clock,canvas); | |
| device.queue.writeBuffer(uniformBuffers[i],0,uniformData); | |
| } | |
| postData.fill(0);postData.set([canvas.width,canvas.height,1/canvas.width,1/canvas.height],0); | |
| renderViews.forEach((view,i)=>{const r=view.rect;postData.set([r.x/canvas.clientWidth,r.y/canvas.clientHeight,r.width/canvas.clientWidth,r.height/canvas.clientHeight],4+i*4);}); | |
| device.queue.writeBuffer(postBuffer,0,postData); | |
| const encoder=device.createCommandEncoder(); | |
| // h and the streamline count are fixed, so the curves are built once. | |
| if (streamlinesDirty) { | |
| const pass=encoder.beginComputePass(); | |
| pass.setPipeline(compute); pass.setBindGroup(0,computeGroup); | |
| pass.dispatchWorkgroups(Math.ceil(THREAD_COUNT/32)); pass.end(); | |
| streamlinesDirty=false; | |
| } | |
| const {sceneView,msaaView,depthView,surfaceView,surfaceMSAAView,resolvedView,bloomAView,bloomBView,wideAView,wideBView,horizontalGroup,verticalGroup,wideHorizontalGroup,wideVerticalGroup,compositeGroup,resolveGroup}=targets.views; | |
| const pass=encoder.beginRenderPass({ | |
| colorAttachments:[{view:msaaView,resolveTarget:sceneView,clearValue:{r:0,g:0,b:0,a:1},loadOp:'clear',storeOp:'discard'},{view:surfaceMSAAView,resolveTarget:surfaceView,clearValue:{r:.5,g:.5,b:1,a:0},loadOp:'clear',storeOp:'discard'}], | |
| depthStencilAttachment:{view:depthView,depthClearValue:1,depthLoadOp:'clear',depthStoreOp:'discard'}, | |
| }); | |
| pass.setPipeline(background); pass.draw(3); | |
| const ratioX=canvas.width/canvas.clientWidth,ratioY=canvas.height/canvas.clientHeight; | |
| for(let i=0;i<renderViews.length;i++){ | |
| const current=renderViews[i],r=current.rect; | |
| const x=Math.round(r.x*ratioX),y=Math.round(r.y*ratioY),w=Math.min(canvas.width-x,Math.max(1,Math.round(r.width*ratioX))),h=Math.min(canvas.height-y,Math.max(1,Math.round(r.height*ratioY))); | |
| pass.setViewport(x,y,w,h,0,1);pass.setScissorRect(x,y,w,h);pass.setBindGroup(0,geometryGroups[i]); | |
| if(current.featured){ | |
| pass.setPipeline(tubes);pass.setIndexBuffer(indexBuffer,'uint16'); | |
| pass.drawIndexed(indexCount,THREAD_COUNT); | |
| } | |
| if(current.pose.particles>.002){pass.setPipeline(storyParticles);pass.draw(6*STORY_TRAIL_SEGMENTS,THREAD_COUNT*MARKERS_PER_THREAD);} | |
| if(current.featured){ | |
| // Leaders and letters share the visible flow depth. | |
| pass.setPipeline(storyParticleDepth);pass.draw(6*STORY_TRAIL_SEGMENTS,THREAD_COUNT*MARKERS_PER_THREAD); | |
| annotationRenderer.draw(pass); | |
| pass.setPipeline(axis);pass.draw(18); | |
| pass.setPipeline(storyAxisGlyphs);pass.setVertexBuffer(0,axisGlyphBuffer);pass.draw(STORY_AXIS_MESH.length/3); | |
| } | |
| } | |
| pass.end(); | |
| fullscreenPass(encoder,resolvedView,resolve,resolveGroup); | |
| fullscreenPass(encoder,bloomAView,horizontal,horizontalGroup); | |
| fullscreenPass(encoder,bloomBView,vertical,verticalGroup); | |
| fullscreenPass(encoder,wideAView,wideHorizontal,wideHorizontalGroup); | |
| fullscreenPass(encoder,wideBView,wideVertical,wideVerticalGroup); | |
| fullscreenPass(encoder,context.getCurrentTexture().createView(),composite,compositeGroup); | |
| device.queue.submit([encoder.finish()]); | |
| renderedFrames++; | |
| frameId=requestAnimationFrame(frame); | |
| } | |
| $('loading').hidden=true; | |
| frameId=requestAnimationFrame(frame); | |
| } | |
| function cleanup() { | |
| destroyed=true; | |
| cancelAnimationFrame(frameId); | |
| resizeObserver?.disconnect(); | |
| for (const resource of resources) resource.destroy(); | |
| context?.unconfigure(); | |
| device?.destroy(); | |
| } | |
| window.addEventListener('pagehide', (event) => { if (!event.persisted) cleanup(); }); | |
| bindControls(); | |
| bindCamera(); | |
| initialize().catch(fail); | |