[READ-ONLY] Mirror of https://github.com/mrgnw/zenfo.co.
0

Configure Feed

Select the types of activity you want to include in your feed.

depth photo improvements: enhanced shaders, renderer updates, UI tweaks

+204 -44
+155 -14
src/lib/components/DepthPhoto.svelte
··· 6 6 src, 7 7 depthSrc, 8 8 alt, 9 - aspect = 1.5, 10 9 intensity = 0.02, 11 - fill = false, 12 10 }: { 13 11 src: string; 14 12 depthSrc: string; 15 13 alt: string; 16 - aspect?: number; 17 14 intensity?: number; 18 - fill?: boolean; 19 15 } = $props(); 20 16 21 17 let canvas: HTMLCanvasElement; 18 + let wrapper: HTMLDivElement; 22 19 let renderer: ParallaxRenderer | null = null; 23 20 let webglSupported = $state(true); 24 21 22 + let isMobile = $state(false); 23 + let motionActive = $state(false); 24 + let promptVisible = $state(false); 25 + let promptText = $state('tilt your phone to look around'); 26 + let promptFading = $state(false); 27 + 28 + function hasMotionSupport(): boolean { 29 + return 'ontouchstart' in window && !!window.DeviceOrientationEvent; 30 + } 31 + 32 + function needsPermission(): boolean { 33 + return typeof (DeviceOrientationEvent as any).requestPermission === 'function'; 34 + } 35 + 36 + function onOrientation(e: DeviceOrientationEvent) { 37 + if (!renderer || !motionActive) return; 38 + const gamma = e.gamma ?? 0; 39 + const beta = e.beta ?? 0; 40 + const x = Math.max(-1, Math.min(1, gamma / 30)); 41 + const y = Math.max(-1, Math.min(1, (beta - 60) / 25)); 42 + renderer.setPointer(x, y); 43 + } 44 + 45 + function dismissPrompt(text?: string) { 46 + if (text) promptText = text; 47 + setTimeout(() => { 48 + promptFading = true; 49 + setTimeout(() => { 50 + promptVisible = false; 51 + promptFading = false; 52 + }, 600); 53 + }, text ? 800 : 0); 54 + } 55 + 56 + function activateMotion() { 57 + motionActive = true; 58 + window.addEventListener('deviceorientation', onOrientation); 59 + dismissPrompt('nice.'); 60 + } 61 + 62 + async function requestMotion() { 63 + if (motionActive) return; 64 + if (needsPermission()) { 65 + try { 66 + const result = await (DeviceOrientationEvent as any).requestPermission(); 67 + if (result === 'granted') { 68 + activateMotion(); 69 + } else { 70 + dismissPrompt(); 71 + } 72 + } catch { 73 + dismissPrompt(); 74 + } 75 + } else { 76 + activateMotion(); 77 + } 78 + } 79 + 25 80 onMount(() => { 26 81 renderer = new ParallaxRenderer(); 27 82 renderer.intensity = intensity; ··· 44 99 ); 45 100 observer.observe(canvas); 46 101 102 + isMobile = hasMotionSupport(); 103 + if (isMobile) { 104 + setTimeout(() => { 105 + if (!motionActive) promptVisible = true; 106 + }, 800); 107 + 108 + if (!needsPermission()) { 109 + setTimeout(() => { 110 + if (!motionActive) activateMotion(); 111 + }, 1200); 112 + } 113 + } 114 + 47 115 return () => { 48 116 observer.disconnect(); 117 + window.removeEventListener('deviceorientation', onOrientation); 49 118 renderer?.destroy(); 50 119 }; 51 120 }); 52 121 53 122 function onPointerMove(e: MouseEvent | TouchEvent) { 54 - if (!renderer) return; 123 + if (!renderer || motionActive) return; 55 124 const rect = canvas.getBoundingClientRect(); 56 125 let clientX: number, clientY: number; 57 126 if ('touches' in e) { ··· 67 136 } 68 137 69 138 function onPointerLeave() { 139 + if (motionActive) return; 70 140 renderer?.setPointer(0, 0); 71 141 } 142 + 143 + function onTap() { 144 + if (isMobile && !motionActive) requestMotion(); 145 + } 72 146 </script> 73 147 74 148 {#if webglSupported} 75 - <canvas 76 - bind:this={canvas} 77 - style={fill ? 'width: 100%; height: 100%;' : `aspect-ratio: ${aspect}; width: 100%;`} 78 - onmousemove={onPointerMove} 79 - ontouchmove={onPointerMove} 80 - onmouseleave={onPointerLeave} 81 - ></canvas> 149 + <div class="depth-photo-wrap" bind:this={wrapper}> 150 + <canvas 151 + bind:this={canvas} 152 + onmousemove={onPointerMove} 153 + ontouchmove={onPointerMove} 154 + onmouseleave={onPointerLeave} 155 + onclick={onTap} 156 + ></canvas> 157 + 158 + {#if promptVisible} 159 + <button 160 + class="motion-prompt" 161 + class:fading={promptFading} 162 + class:rocking={!motionActive} 163 + onclick={onTap} 164 + > 165 + {promptText} 166 + </button> 167 + {/if} 168 + </div> 82 169 {:else} 83 - <img {src} {alt} style={fill ? 'width: 100%; height: 100%; object-fit: cover;' : `aspect-ratio: ${aspect}; width: 100%; object-fit: cover;`} /> 170 + <img {src} {alt} /> 84 171 {/if} 85 172 86 173 <style> 174 + .depth-photo-wrap { 175 + position: relative; 176 + width: 100%; 177 + height: 100%; 178 + } 87 179 canvas { 88 180 display: block; 89 - cursor: crosshair; 181 + width: 100%; 182 + height: 100%; 183 + cursor: default; 90 184 } 91 185 img { 92 186 display: block; 187 + width: 100%; 188 + height: 100%; 189 + object-fit: contain; 190 + } 191 + .motion-prompt { 192 + all: unset; 193 + position: absolute; 194 + bottom: 1.5rem; 195 + left: 50%; 196 + transform: translateX(-50%); 197 + background: rgba(0, 0, 0, 0.55); 198 + backdrop-filter: blur(12px); 199 + -webkit-backdrop-filter: blur(12px); 200 + color: var(--color-text-muted); 201 + font-size: 0.7rem; 202 + letter-spacing: 0.08em; 203 + padding: 0.4rem 0.9rem; 204 + border-radius: 999px; 205 + cursor: pointer; 206 + white-space: nowrap; 207 + opacity: 1; 208 + animation: prompt-in 0.6s ease both; 209 + transition: opacity 0.6s ease, color 0.3s ease; 210 + } 211 + .motion-prompt:hover { 212 + color: var(--color-text); 213 + } 214 + .motion-prompt.fading { 215 + opacity: 0; 216 + } 217 + .motion-prompt.rocking { 218 + animation: prompt-in 0.6s ease both, rock 2.5s ease-in-out 0.6s infinite; 219 + } 220 + @keyframes prompt-in { 221 + from { 222 + opacity: 0; 223 + transform: translateX(-50%) translateY(6px); 224 + } 225 + to { 226 + opacity: 1; 227 + transform: translateX(-50%) translateY(0); 228 + } 229 + } 230 + @keyframes rock { 231 + 0%, 100% { transform: translateX(-50%) translateY(0) rotate(0deg); } 232 + 25% { transform: translateX(-50%) translateY(0) rotate(-2deg); } 233 + 75% { transform: translateX(-50%) translateY(0) rotate(2deg); } 93 234 } 94 235 </style>
+1 -1
src/lib/components/DepthSlice.svelte
··· 141 141 width: 100%; 142 142 height: 100%; 143 143 background: #000; 144 - cursor: crosshair; 144 + cursor: default; 145 145 } 146 146 canvas { 147 147 max-width: 100%;
+13 -7
src/lib/components/PhotoGrid.svelte
··· 14 14 <div class="grid"> 15 15 {#each photos as photo, i} 16 16 <button class="grid-item" onclick={() => onselect?.(i)}> 17 - <DepthPhoto 18 - src={photo.src} 19 - depthSrc={photo.depthSrc} 20 - alt={photo.title} 21 - aspect={photo.aspect} 22 - intensity={0.01} 23 - /> 17 + <div class="photo-frame"> 18 + <DepthPhoto 19 + src={photo.src} 20 + depthSrc={photo.depthSrc} 21 + alt={photo.title} 22 + intensity={0.01} 23 + /> 24 + </div> 24 25 <span class="title">{photo.title}</span> 25 26 </button> 26 27 {/each} ··· 42 43 flex-direction: column; 43 44 gap: 0.5rem; 44 45 transition: transform 0.2s ease; 46 + } 47 + .photo-frame { 48 + aspect-ratio: 3 / 4; 49 + overflow: hidden; 50 + border-radius: 2px; 45 51 } 46 52 .grid-item:hover { 47 53 transform: scale(1.01);
+2 -2
src/lib/components/PhotoViewer.svelte
··· 51 51 src={photo.src} 52 52 depthSrc={photo.depthSrc} 53 53 alt={photo.title} 54 - aspect={photo.aspect} 55 54 intensity={0.02} 56 55 /> 57 56 {:else} ··· 116 115 gap: 1rem; 117 116 } 118 117 .photo-wrap { 119 - max-height: 80vh; 118 + width: 90vw; 119 + height: 80vh; 120 120 overflow: hidden; 121 121 } 122 122 .caption {
+8
src/lib/gl/renderer.ts
··· 61 61 private pointerCurrent = { x: 0, y: 0 }; 62 62 63 63 private locs: Record<string, WebGLUniformLocation | null> = {}; 64 + private imageWidth = 1; 65 + private imageHeight = 1; 64 66 65 67 intensity = 0.02; 66 68 ··· 90 92 u_depth: gl.getUniformLocation(this.program, 'u_depth'), 91 93 u_pointer: gl.getUniformLocation(this.program, 'u_pointer'), 92 94 u_intensity: gl.getUniformLocation(this.program, 'u_intensity'), 95 + u_resolution: gl.getUniformLocation(this.program, 'u_resolution'), 96 + u_imageRes: gl.getUniformLocation(this.program, 'u_imageRes'), 93 97 }; 94 98 95 99 gl.uniform1i(this.locs.u_image, 0); ··· 101 105 async loadTextures(imageUrl: string, depthUrl: string) { 102 106 if (!this.gl) return; 103 107 const [img, depth] = await Promise.all([loadImage(imageUrl), loadImage(depthUrl)]); 108 + this.imageWidth = img.naturalWidth; 109 + this.imageHeight = img.naturalHeight; 104 110 this.imageTex = createTexture(this.gl, img); 105 111 this.depthTex = createTexture(this.gl, depth); 106 112 } ··· 158 164 gl.useProgram(this.program); 159 165 gl.uniform2f(this.locs.u_pointer, this.pointerCurrent.x + driftX, this.pointerCurrent.y + driftY); 160 166 gl.uniform1f(this.locs.u_intensity, this.intensity); 167 + gl.uniform2f(this.locs.u_resolution, canvas.width, canvas.height); 168 + gl.uniform2f(this.locs.u_imageRes, this.imageWidth, this.imageHeight); 161 169 162 170 if (this.imageTex) { 163 171 gl.activeTexture(gl.TEXTURE0);
+24 -6
src/lib/gl/shaders.ts
··· 3 3 varying vec2 vUv; 4 4 5 5 void main() { 6 - vUv = a_position * 0.5 + 0.5; 6 + vUv = vec2(a_position.x * 0.5 + 0.5, 0.5 - a_position.y * 0.5); 7 7 gl_Position = vec4(a_position, 0.0, 1.0); 8 8 } 9 9 `; ··· 16 16 uniform sampler2D u_depth; 17 17 uniform vec2 u_pointer; 18 18 uniform float u_intensity; 19 + uniform vec2 u_resolution; 20 + uniform vec2 u_imageRes; 19 21 20 22 vec2 mirrored(vec2 v) { 21 23 vec2 m = mod(v, 2.0); ··· 23 25 } 24 26 25 27 void main() { 26 - float depth = texture2D(u_depth, vUv).r; 28 + float canvasAspect = u_resolution.x / u_resolution.y; 29 + float imageAspect = u_imageRes.x / u_imageRes.y; 30 + 31 + vec2 uv = vUv; 32 + if (imageAspect > canvasAspect) { 33 + float scale = canvasAspect / imageAspect; 34 + uv.y = (uv.y - 0.5) / scale + 0.5; 35 + } else { 36 + float scale = imageAspect / canvasAspect; 37 + uv.x = (uv.x - 0.5) / scale + 0.5; 38 + } 39 + 40 + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { 41 + gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); 42 + return; 43 + } 44 + 45 + float depth = texture2D(u_depth, uv).r; 27 46 vec2 offset = u_pointer * depth * u_intensity; 28 - vec2 uv = mirrored(vUv + offset); 29 - vec4 color = texture2D(u_image, uv); 47 + vec2 displaced = mirrored(uv + offset); 48 + vec4 color = texture2D(u_image, displaced); 30 49 31 - // depth-based vignette: darken edges more for far pixels 32 - vec2 vc = vUv * 2.0 - 1.0; 50 + vec2 vc = uv * 2.0 - 1.0; 33 51 float vignette = 1.0 - dot(vc, vc) * (0.15 + 0.2 * (1.0 - depth)); 34 52 color.rgb *= clamp(vignette, 0.0, 1.0); 35 53
-1
src/lib/types.ts
··· 4 4 caption?: string; 5 5 src: string; 6 6 depthSrc: string; 7 - aspect: number; 8 7 }
+1 -13
src/routes/+page.svelte
··· 10 10 caption: 'Alpine reflections', 11 11 src: '/samples/sample.jpg', 12 12 depthSrc: '/samples/sample-depth.jpg', 13 - aspect: 1.5, 14 13 }, 15 14 { 16 15 slug: 'mountain-lake-2', ··· 18 17 caption: 'Last light on the peaks', 19 18 src: '/samples/sample.jpg', 20 19 depthSrc: '/samples/sample-depth.jpg', 21 - aspect: 1.5, 22 20 }, 23 21 { 24 22 slug: 'mountain-lake-3', ··· 26 24 caption: 'Dawn at the lake', 27 25 src: '/samples/sample.jpg', 28 26 depthSrc: '/samples/sample-depth.jpg', 29 - aspect: 1.5, 30 27 }, 31 28 ]; 32 29 ··· 125 122 src={p.src} 126 123 depthSrc={p.depthSrc} 127 124 alt={p.title} 128 - aspect={p.aspect} 129 125 intensity={0.02} 130 - fill 131 126 /> 132 127 {/key} 133 128 {/if} ··· 195 190 width: 100vw; 196 191 scroll-snap-align: start; 197 192 position: relative; 198 - display: flex; 199 - align-items: center; 200 - justify-content: center; 201 193 overflow: hidden; 202 - } 203 - 204 - .slide :global(canvas) { 205 - width: 100vw !important; 206 - height: 100vh !important; 194 + background: #000; 207 195 } 208 196 209 197 .overlay {