msdf splash text

This commit is contained in:
nicwands 2026-07-22 13:12:13 -04:00
parent 2ba24ff7c2
commit 777b7f9a6d
22 changed files with 653 additions and 30 deletions

12
app/bin/generateMSDF.sh Normal file
View file

@ -0,0 +1,12 @@
#!/bin/bash
BASE_DIR=/home/nicwands/Documents/Dev/freelance/portfolio/braindrops.nicwands.com/app/
msdf-atlas-gen \
-font "$BASE_DIR"/fonts/InstrumentSerif-Italic.ttf \
-fontname 'instrument-serif' \
-imageout instrument-serif-atlas.png \
-json instrument-serif-atlas.json \
-pxrange 14 \
-dimensions 1024 1024 \
-errorcorrection auto

View file

@ -14,12 +14,18 @@
"singleQuote": true
},
"dependencies": {
"@mapbox/tiny-sdf": "^2.2.0",
"@tiptap/pm": "^3.28.0",
"@tiptap/starter-kit": "^3.28.0",
"@tiptap/vue-3": "^3.28.0",
"@tresjs/cientos": "^5.8.1",
"@tresjs/core": "^5.8.3",
"fecha": "^4.2.3",
"fontfaceobserver": "^2.3.0",
"lodash": "^4.18.1",
"sass": "^1.98.0",
"sass-embedded": "^1.98.0",
"vite-plugin-glsl": "^1.6.0",
"vue": "^3.5.30",
"vue-router": "^5.2.0"
},

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

View file

@ -1,5 +1,5 @@
<template>
<main class="container">
<main :class="['container', { 'fonts-loaded': fontsLoaded }]">
<router-view :manifest="manifest" :key="route.fullPath" />
</main>
</template>
@ -7,18 +7,43 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import loadFonts from '@/libs/loadFonts'
const route = useRoute()
const manifest = ref([])
const fontsLoaded = ref(false)
onMounted(async () => {
manifest.value = await fetch('/manifest.json').then((r) => r.json())
await loadFonts([
{
name: 'Instrument Serif',
styles: ['italic'],
weights: [400]
},
{
name: 'Hanken Grotesk',
styles: ['normal', 'italic'],
weights: [400, 600]
},
{
name: 'Iosevka Charon Mono',
styles: ['normal'],
weights: [700]
},
])
fontsLoaded.value = true
})
</script>
<style lang="scss">
.container {
background: var(--theme-layout);
transition: opacity 600ms ease-out;
&:not(.fonts-loaded) {
opacity: 0;
}
}
</style>

View file

@ -0,0 +1,280 @@
<template>
<!-- Full-screen background plane for metaball visibility -->
<TresMesh ref="bgPlane" :position="[0, 0, -0.1]">
<TresPlaneGeometry :args="[10, 10]" />
<TresShaderMaterial v-bind="bgMaterialProps" />
</TresMesh>
<!-- Text mesh -->
<TresMesh ref="textMesh">
<TresBufferGeometry :ref="(el) => el && updateGeometry(el)" />
<TresShaderMaterial v-bind="materialProps" />
</TresMesh>
</template>
<script setup>
import vertShader from '@/libs/glsl/splash-title/vert.glsl'
import fragShader from '@/libs/glsl/splash-title/frag.glsl'
import useSmoothMouse from '@/composables/useSmoothMouse'
import { useWindowSize } from '@vueuse/core'
import { useLoop } from '@tresjs/core'
import { shallowRef, onMounted, ref, watch } from 'vue'
import { TextureLoader, LinearFilter, BufferAttribute } from 'three'
// Props
const props = defineProps({
text: {
type: String,
default: 'Braindrops'
},
fontFamily: {
type: String,
default: 'instrument-serif'
},
fontSize: {
type: Number,
default: 0.2 // Scale factor relative to screen width
},
letterSpacing: {
type: Number,
default: 0 // Additional spacing in em units (can be negative)
},
blobStrength: {
type: Number,
default: 1.0 // Controls metaball merge distance (higher = merges from farther away)
}
})
const { width, height } = useWindowSize()
const textMesh = shallowRef()
const bgPlane = shallowRef()
const atlasData = ref(null)
const atlasTexture = shallowRef(null)
// Load MSDF atlas
onMounted(async () => {
const [jsonData, texture] = await Promise.all([
fetch(`/splash-title/${props.fontFamily}-atlas.json`).then(r => r.json()),
new TextureLoader().loadAsync(`/splash-title/${props.fontFamily}-atlas.png`)
])
console.log(jsonData, texture)
atlasData.value = jsonData
atlasTexture.value = texture
texture.minFilter = LinearFilter
texture.magFilter = LinearFilter
texture.needsUpdate = true
// Trigger geometry update
if (textMesh.value) {
updateGeometry(textMesh.value.geometry)
}
})
// Watch for props changes and update geometry
watch(() => [props.text, props.fontFamily, props.fontSize, props.letterSpacing], () => {
if (textMesh.value && atlasData.value) {
updateGeometry(textMesh.value.geometry)
}
})
// Watch for window size changes and update geometry
watch([width, height], () => {
if (textMesh.value && atlasData.value) {
updateGeometry(textMesh.value.geometry)
}
})
// Create text layout and geometry
function layoutText(text, fontData) {
const glyphMap = new Map()
fontData.glyphs.forEach(g => {
glyphMap.set(g.unicode, g)
})
const positions = []
const uvs = []
const indices = []
// First pass: measure total width including letter spacing
let totalWidth = 0
const chars = text.split('')
chars.forEach((char, i) => {
const code = char.charCodeAt(0)
const glyph = glyphMap.get(code)
if (glyph) {
totalWidth += glyph.advance
// Add letter spacing between characters (but not after the last one)
if (i < chars.length - 1) {
totalWidth += props.letterSpacing
}
}
})
// Calculate scale to fit within desired width (responsive to screen aspect)
const aspect = width.value / height.value
const targetWidth = props.fontSize * 2 * aspect // Convert to normalized coordinates
const scale = totalWidth > 0 ? targetWidth / totalWidth : 1
const atlasW = fontData.atlas.width
const atlasH = fontData.atlas.height
let cursorX = 0
let vertexCount = 0
chars.forEach((char, i) => {
const code = char.charCodeAt(0)
const glyph = glyphMap.get(code)
if (!glyph || !glyph.planeBounds) {
cursorX += (glyph?.advance || 0) * scale
// Add letter spacing
if (i < chars.length - 1) {
cursorX += props.letterSpacing * scale
}
return
}
const pb = glyph.planeBounds
const ab = glyph.atlasBounds
// Quad corners in plane space (scaled)
const x0 = cursorX + pb.left * scale
const y0 = pb.bottom * scale
const x1 = cursorX + pb.right * scale
const y1 = pb.top * scale
// UV coordinates (atlas space, Y flipped for WebGL)
const u0 = ab.left / atlasW
const v0 = 1.0 - ab.top / atlasH
const u1 = ab.right / atlasW
const v1 = 1.0 - ab.bottom / atlasH
// Vertex index offset for this quad
const vIdx = vertexCount
vertexCount += 4
// Four vertices (bottom-left, bottom-right, top-right, top-left)
positions.push(
x0, y0, 0,
x1, y0, 0,
x1, y1, 0,
x0, y1, 0
)
uvs.push(
u0, v1,
u1, v1,
u1, v0,
u0, v0
)
// Two triangles
indices.push(
vIdx, vIdx + 1, vIdx + 2,
vIdx, vIdx + 2, vIdx + 3
)
// Advance cursor by glyph width plus letter spacing
cursorX += glyph.advance * scale
if (i < chars.length - 1) {
cursorX += props.letterSpacing * scale
}
})
// Center the text
const actualWidth = cursorX
const offsetX = -actualWidth / 2
for (let i = 0; i < positions.length; i += 3) {
positions[i] += offsetX
}
return { positions, uvs, indices, scale }
}
// Update geometry when atlas is loaded
function updateGeometry(geometry) {
if (!atlasData.value) return
const { positions, uvs, indices, scale } = layoutText(props.text, atlasData.value)
geometry.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3))
geometry.setAttribute('uv', new BufferAttribute(new Float32Array(uvs), 2))
geometry.setIndex(indices)
geometry.computeBoundingBox()
geometry.computeBoundingSphere()
// Update uniforms with current scale for MSDF quality
if (textMesh.value?.material) {
textMesh.value.material.uniforms.u_text_scale.value = scale
}
}
useSmoothMouse({
normalize: true,
onUpdate: ({ sx, sy }) => {
const mousePos = [sx, 1 - sy]
if (textMesh.value?.material) {
textMesh.value.material.uniforms.u_mouse.value = mousePos
}
if (bgPlane.value?.material) {
bgPlane.value.material.uniforms.u_mouse.value = mousePos
}
},
})
const materialProps = {
vertexShader: vertShader,
fragmentShader: fragShader,
transparent: true,
depthWrite: false,
uniforms: {
u_resolution: { value: [width.value, height.value] },
u_mouse: { value: [0.5, 0.5] },
u_msdf: { value: atlasTexture.value },
u_atlas_size: { value: [212, 212] },
u_text_scale: { value: 1.0 },
u_distance_range: { value: 2.0 }, // From atlas distanceRange
u_is_text: { value: 1.0 }, // Flag to indicate this is text
u_blob_strength: { value: props.blobStrength },
},
}
const bgMaterialProps = {
vertexShader: vertShader,
fragmentShader: fragShader,
transparent: true,
depthWrite: false,
uniforms: {
u_resolution: { value: [width.value, height.value] },
u_mouse: { value: [0.5, 0.5] },
u_msdf: { value: atlasTexture.value },
u_atlas_size: { value: [212, 212] },
u_text_scale: { value: 1.0 },
u_distance_range: { value: 2.0 },
u_is_text: { value: 0.0 }, // Background only shows metaball
u_blob_strength: { value: props.blobStrength },
},
}
const { onRender } = useLoop()
onRender(() => {
const resolution = [width.value, height.value]
if (textMesh.value?.material) {
textMesh.value.material.uniforms.u_resolution.value = resolution
textMesh.value.material.uniforms.u_blob_strength.value = props.blobStrength
// Update atlas texture reference if it changed
if (atlasTexture.value && textMesh.value.material.uniforms.u_msdf.value !== atlasTexture.value) {
textMesh.value.material.uniforms.u_msdf.value = atlasTexture.value
}
}
if (bgPlane.value?.material) {
bgPlane.value.material.uniforms.u_resolution.value = resolution
bgPlane.value.material.uniforms.u_blob_strength.value = props.blobStrength
}
})
</script>

View file

@ -0,0 +1,54 @@
<template>
<div class="webgl-canvas">
<TresCanvas
v-bind="{
powerPreference: 'high-performance',
alpha: true,
clearAlpha: 0,
...options,
}"
>
<!-- <TresOrthographicCamera
:args="[0, 1, 0, 1, 0.001, 5000]"
:position="[0, 0, -1000]"
:look-at="[0, 0, 0]"
/> -->
<TresOrthographicCamera
:args="[1 / -2, 1 / 2, 1 / 2, 1 / -2, 0.001, 5000]"
:position="[0, 0, 1000]"
/>
<TresAmbientLight />
<TresPointLight />
<OrbitControls v-if="controls" />
<slot />
</TresCanvas>
</div>
</template>
<script setup>
import { TresCanvas } from '@tresjs/core'
import { OrbitControls } from '@tresjs/cientos'
const { controls, perspective } = defineProps({
options: Object,
controls: Boolean
})
</script>
<style lang="scss">
.webgl-canvas {
position: absolute;
inset: 0;
&:not(.controls) {
pointer-events: none;
.sticky canvas {
pointer-events: none !important;
}
}
}
</style>

View file

@ -0,0 +1,89 @@
import gsap from 'gsap'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useWindowSize, useEventListener } from '@vueuse/core'
/**
* Shared global raw mouse state (only set up once)
*/
const rawMouse = {
x: ref(0),
y: ref(0),
initialized: false,
cleanup: null,
}
const useGlobalMouseListener = () => {
if (!rawMouse.initialized && !import.meta.env.SSR) {
rawMouse.initialized = true
rawMouse.cleanup = useEventListener(window, 'mousemove', (e) => {
rawMouse.x.value = e.clientX
rawMouse.y.value = e.clientY
})
}
}
/**
* Composable for smoothed mouse position with customizable smoothing and normalization.
*/
export default (options) => {
const smoothFactor = options?.smoothFactor ?? 0.1
const normalize = options?.normalize ?? false
const callback = options?.onUpdate
const { width: wWidth, height: wHeight } = useWindowSize()
const sx = ref(0)
const sy = ref(0)
useGlobalMouseListener()
const getTargetX = () =>
normalize ? rawMouse.x.value / wWidth.value : rawMouse.x.value
const getTargetY = () =>
normalize ? rawMouse.y.value / wHeight.value : rawMouse.y.value
let tween
onMounted(() => {
if (tween) tween.kill
// Start smoothing tween
tween = gsap.to(
{ x: sx.value, y: sy.value },
{
duration: 1,
ease: 'linear',
repeat: -1,
onUpdate: () => {
const newX = gsap.utils.interpolate(
sx.value,
getTargetX(),
smoothFactor,
)
const newY = gsap.utils.interpolate(
sy.value,
getTargetY(),
smoothFactor,
)
sx.value = newX
sy.value = newY
callback?.({ sx: sx.value, sy: sy.value })
},
},
)
})
onBeforeUnmount(() => {
tween?.kill()
rawMouse.cleanup()
rawMouse.initialized = false
})
return {
sx,
sy,
rawX: rawMouse.x,
rawY: rawMouse.y,
}
}

View file

@ -0,0 +1,57 @@
precision highp float;
varying vec2 v_uv;
varying vec2 v_screen_uv;
uniform vec2 u_resolution;
uniform vec2 u_mouse;
uniform sampler2D u_msdf;
uniform vec2 u_atlas_size;
uniform float u_text_scale;
uniform float u_distance_range;
uniform float u_is_text;
uniform float u_blob_strength;
// MSDF median function - extracts signed distance from multi-channel texture
float median(float r, float g, float b) {
return max(min(r, g), min(max(r, g), b));
}
void main() {
float aspect = u_resolution.x / u_resolution.y;
float tf = 0.0;
if (u_is_text > 0.5) {
// Sample MSDF atlas - v_uv already contains the correct atlas coordinates
vec3 msdfSample = texture2D(u_msdf, vec2(v_uv.x, 1. - v_uv.y)).rgb;
float msdf = median(msdfSample.r, msdfSample.g, msdfSample.b);
// Proper MSDF distance field scaling for high quality rendering
// Calculate screen-space derivatives to determine proper threshold
vec2 unitRange = u_distance_range / u_atlas_size;
vec2 screenTexSize = vec2(1.0) / fwidth(v_uv);
float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0);
float screenPxDistance = screenPxRange * (msdf - 0.5);
// Convert MSDF distance to a field value centered at 0.5
// This creates a smooth falloff around the glyph edge
tf = screenPxDistance / screenPxRange + 0.5;
}
// Mouse blob: smooth circular falloff in screen space, aspect-corrected
float blobRadius = 0.05;
vec2 d = (v_screen_uv - u_mouse) * vec2(aspect, 1.0);
float bf = 1.0 - smoothstep(0.0, blobRadius, length(d));
// Metaball field sum: scale blob contribution to control merge distance
// Higher u_blob_strength = blob merges from farther away
// Lower u_blob_strength = blob must get closer to merge
float field = tf + (bf * u_blob_strength);
// Anti-aliased edge at the 0.5 isosurface
float fw = fwidth(field);
float alpha = smoothstep(0.5 - fw, 0.5 + fw, field);
gl_FragColor = vec4(0.0, 0.0, 0.0, alpha);
}

View file

@ -0,0 +1,12 @@
varying vec2 v_uv;
varying vec2 v_screen_uv;
void main() {
v_uv = uv;
// Compute screen-space UV for mouse interaction
vec4 clipPos = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
v_screen_uv = clipPos.xy / clipPos.w * 0.5 + 0.5;
gl_Position = clipPos;
}

50
app/src/libs/loadFonts.js Normal file
View file

@ -0,0 +1,50 @@
import _uniq from 'lodash/uniq'
import FontFaceObserver from 'fontfaceobserver'
const resolveWeights = (font) => {
const declared = font.weights || []
const single = font.weight ? [font.weight] : []
const all = _uniq(declared.concat(single))
return all.length ? all : [400]
}
const resolveStyles = (font) => {
const declared = font.styles || []
const single = font.style ? [font.style] : []
const all = _uniq(declared.concat(single))
return all.length ? all : ['normal']
}
// Primary load function
export default (ops) => {
const fonts = Array.isArray(ops) ? ops : [ops]
const families = []
// declare all fonts
fonts.forEach((font) => {
// resolve weights & styles
const weights = resolveWeights(font)
const styles = resolveStyles(font)
// split all into separate declarations
weights.forEach((weight) => {
styles.forEach((style) => {
families.push({
name: font.name,
weight,
style,
})
})
})
})
// Load all in parallel
return Promise.all(
families.map((fam) => {
const font = new FontFaceObserver(fam.name, {
weight: fam.weight,
style: fam.style,
})
return font.load()
}),
)
}

View file

@ -1,20 +1,40 @@
<template>
<main class="home theme-light">
<h1>Braindrops</h1>
<btn @click="onClick">View Drops</btn>
<webgl-canvas>
<splash-title
v-if="ready"
fontFamily="instrument-serif"
:letterSpacing="-0.05"
:blobStrength="1"
/>
</webgl-canvas>
<div class="content">
<h1>Braindrops</h1>
<btn @click="onClick">View Drops</btn>
</div>
</main>
</template>
<script setup>
import Btn from '@shared/Btn.vue'
import SplashTitle from '@/components/SplashTitle.vue'
import WebglCanvas from '@/components/WebglCanvas.vue'
import { useRouter } from 'vue-router';
import Btn from '@shared/Btn.vue'
import { ref } from 'vue';
const props = defineProps({
manifest: Array,
})
const ready = ref(false)
const router = useRouter()
document.fonts.ready.then(() => {
ready.value = true
})
const onClick = () => {
router.push(`/${props.manifest[0]}`)
}
@ -27,10 +47,21 @@ const onClick = () => {
height: 100vh;
padding-top: rs(20px);
padding-bottom: rs(20px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: rs(20px)
.content {
height: 100vh;
position: relative;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: rs(20px);
h1 {
opacity: 0;
}
}
}
</style>

View file

@ -0,0 +1,3 @@
.webgl-canvas {
z-index: 0;
}

View file

@ -1,10 +1,11 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import { defineConfig, mergeConfig } from 'vite'
import sharedConfig from '../shared/vite.config.js'
import fs from 'node:fs'
import path from 'node:path'
import glsl from 'vite-plugin-glsl'
export default defineConfig({
export default defineConfig(mergeConfig(sharedConfig, {
...sharedConfig,
resolve: {
alias: {
@ -13,7 +14,7 @@ export default defineConfig({
},
},
plugins: [
...sharedConfig.plugins,
glsl(),
{
name: 'serve-drops',
configureServer(server) {
@ -62,4 +63,4 @@ export default defineConfig({
},
},
],
})
}))

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

35
package-lock.json generated
View file

@ -18,12 +18,18 @@
"name": "braindrops-app",
"version": "0.0.0",
"dependencies": {
"@mapbox/tiny-sdf": "^2.2.0",
"@tiptap/pm": "^3.28.0",
"@tiptap/starter-kit": "^3.28.0",
"@tiptap/vue-3": "^3.28.0",
"@tresjs/cientos": "^5.8.1",
"@tresjs/core": "^5.8.3",
"fecha": "^4.2.3",
"fontfaceobserver": "^2.3.0",
"lodash": "^4.18.1",
"sass": "^1.98.0",
"sass-embedded": "^1.98.0",
"vite-plugin-glsl": "^1.6.0",
"vue": "^3.5.30",
"vue-router": "^5.2.0"
},
@ -114,19 +120,6 @@
"vite": "^8.0.0"
}
},
"drops/testing": {
"version": "0.0.0",
"dependencies": {
"@fuzzco/font-loader": "^1.0.2",
"sass": "^1.98.0",
"sass-embedded": "^1.98.0",
"vue": "^3.5.30"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.0"
}
},
"node_modules/@babel/generator": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
@ -361,6 +354,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@ -2324,6 +2323,12 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -3232,10 +3237,6 @@
"node": ">=16.0.0"
}
},
"node_modules/testing": {
"resolved": "drops/testing",
"link": true
},
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",