Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ This file tracks Codex progress and upcoming tasks. Keep it chronological and ap
- **72** – Added cleanup for shader materials and geometries in `useFeedbackFBO` when passes change. Lint and build pass.
- **73** – Disabled depth buffers in feedback render targets to reduce memory usage. Lint and build pass.
- **74** – Introduced generic `Pass` type with `setup` and `render` hooks and refactored shader passes and `useFeedbackFBO` to use it. Lint and build pass.
- **75** – Added scene pass infrastructure with ASCII luminance effect and `asciiDecay` stack. Updated controls, README, and types. Lint passes with warning; build succeeds.
- **76** – Reworked ASCII luminance into a GPU shader pass and fixed pass cleanup when switching effects. Lint and build pass.
- **77** – Fixed ASCII luminance shader to sample glyph alpha so characters render correctly. Lint and build pass.
- **78** – Fixed ASCII grid orientation and reset snapshot state when idle to prevent frozen output. Lint and build pass.
- **79** – Flipped shader Y axis and cleared the ASCII pass buffer each frame to keep text upright and prevent stuck frames. Lint and build pass.
- **80** – Corrected ASCII pass orientation and rotated glyphs 180° so they display upright. Lint and build pass.

## Next Steps

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ App
Each effect in `src/effects` declares an ordered list of passes following an
implicit snapshot. Every frame starts by rendering the current foreground pose
into a snapshot buffer; the resulting texture becomes the input for the first
pass. A pass is `{ type: 'shader', fragment }`.
pass. A pass is typically `{ type: 'shader', fragment }`, but effects can also
include custom scene passes that render arbitrary Three.js content. `asciiDecay`
uses this to draw a grid of text characters.
`useFeedbackFBO` steps through the passes sequentially, piping textures from one
to the next. Each shader receives uniforms:

Expand Down Expand Up @@ -161,7 +163,7 @@ starting state:
| ------- | ------------------------------------------ | ------------ | ------------------------------------------------------ |
| `text` | Any string | `Hello` | Initial text content when the foreground source is text |
| `bg` | `wildflowers`, `white` | `white` | Background image selection |
| `effect`| `motionBlur`, `randomPaint`, `rippleFade`, `blurredRipple` | `rippleFade` | Starting feedback effect |
| `effect`| `motionBlur`, `randomPaint`, `rippleFade`, `blurredRipple`, `asciiDecay` | `rippleFade` | Starting feedback effect |

Effect names correspond to entries in
[`src/effects/index.ts`](src/effects/index.ts). They can contain multiple passes
Expand All @@ -176,4 +178,4 @@ Runtime controls are exposed via a Tweakpane panel. Defaults come from
- **Effect:** `rippleFade` with decay `0.98`
- **Paint While Still:** `false`
- **Effect Params:** blur radius `1`, ripple speed `0.05`, displacement `0.0015`,
detail `2`, zoom `0`, center zoom `false`
detail `2`, zoom `0`, center zoom `false`, character width `12`
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default function App() {
const [detail, setDetail] = useState(2)
const [zoom, setZoom] = useState(0)
const [centerZoom, setCenterZoom] = useState(false)
const [charWidth, setCharWidth] = useState(12)
const style =
bgName === 'wildflowers'
? { backgroundImage: `url(${wildflowersUrl})` }
Expand Down Expand Up @@ -74,6 +75,8 @@ export default function App() {
setZoom={setZoom}
centerZoom={centerZoom}
setCenterZoom={setCenterZoom}
charWidth={charWidth}
setCharWidth={setCharWidth}
/>
<div
className="w-screen overflow-hidden bg-cover bg-center"
Expand All @@ -92,6 +95,7 @@ export default function App() {
detail={detail}
zoom={zoom}
centerZoom={centerZoom}
charWidth={charWidth}
onInteract={() => setOverviewHidden(true)}
/>
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/components/CanvasStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type CanvasStageProps = {
detail: number
zoom: number
centerZoom: boolean
charWidth: number
onInteract: () => void
}

Expand All @@ -33,6 +34,7 @@ export default function CanvasStage({
detail,
zoom,
centerZoom,
charWidth,
onInteract,
}: CanvasStageProps) {
return (
Expand All @@ -51,6 +53,7 @@ export default function CanvasStage({
detail={detail}
zoom={zoom}
centerZoom={centerZoom}
charWidth={charWidth}
onInteract={onInteract}
/>
</Suspense>
Expand Down
9 changes: 9 additions & 0 deletions src/components/DemoControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export type DemoControlsProps = {
setZoom: (val: number) => void
centerZoom: boolean
setCenterZoom: (val: boolean) => void
charWidth: number
setCharWidth: (val: number) => void
svgSize: SvgSize
setSvgSize: (size: SvgSize) => void
paintWhileStill: boolean
Expand Down Expand Up @@ -65,6 +67,8 @@ export default function DemoControls({
setZoom,
centerZoom,
setCenterZoom,
charWidth,
setCharWidth,
svgSize,
setSvgSize,
paintWhileStill,
Expand All @@ -81,6 +85,7 @@ export default function DemoControls({
const zoomRef = useRef(zoom)
const centerZoomRef = useRef(centerZoom)
const blurRadiusRef = useRef(blurRadius)
const charWidthRef = useRef(charWidth)
const containerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
sizeRef.current = svgSize
Expand All @@ -103,6 +108,9 @@ export default function DemoControls({
useEffect(() => {
blurRadiusRef.current = blurRadius
}, [blurRadius])
useEffect(() => {
charWidthRef.current = charWidth
}, [charWidth])
/* eslint-disable react-hooks/exhaustive-deps */
useEffect(() => {
const pane = new Pane({ container: containerRef.current ?? undefined })
Expand All @@ -125,6 +133,7 @@ export default function DemoControls({
detail: { ref: detailRef, setter: setDetail },
zoom: { ref: zoomRef, setter: setZoom },
centerZoom: { ref: centerZoomRef, setter: setCenterZoom },
charWidth: { ref: charWidthRef, setter: setCharWidth },
}

const createEffectParamsFolder = (shader: EffectName) => {
Expand Down
3 changes: 3 additions & 0 deletions src/components/DraggableForeground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type DraggableForegroundProps = {
detail: number
zoom: number
centerZoom: boolean
charWidth: number
onGrab?: () => void
}

Expand All @@ -37,6 +38,7 @@ export default function DraggableForeground({
detail,
zoom,
centerZoom,
charWidth,
onGrab,
}: DraggableForegroundProps) {
const dragRef = useRef<THREE.Group | null>(null)
Expand All @@ -55,6 +57,7 @@ export default function DraggableForeground({
detail,
zoom,
centerZoom,
charWidth,
}
const passParams = passes.map((p) => {
const obj: Record<string, number | boolean> = {}
Expand Down
3 changes: 3 additions & 0 deletions src/components/ForegroundLayerDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type ForegroundLayerDemoProps = {
detail: number
zoom: number
centerZoom: boolean
charWidth: number
onInteract: () => void
}

Expand All @@ -38,6 +39,7 @@ export default function ForegroundLayerDemo({
detail,
zoom,
centerZoom,
charWidth,
onInteract,
}: ForegroundLayerDemoProps) {
const svgUrl = useSvgUrl()
Expand All @@ -61,6 +63,7 @@ export default function ForegroundLayerDemo({
detail={detail}
zoom={zoom}
centerZoom={centerZoom}
charWidth={charWidth}
onGrab={onInteract}
/>
</>
Expand Down
135 changes: 135 additions & 0 deletions src/effects/asciiLuminance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import * as THREE from 'three'
import type { ScenePass, PassSetupContext, PassRenderContext } from './pass'

const asciiChars = [
'█','▓','▒','░','@','#','M','W','&','8','B','%','Q','D','O','0','G','H','K','X',
'N','U','Z','Y','C','V','J','L','I','!',';',',','.','`',' '
]

const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`

const fragmentShader = `
varying vec2 vUv;
uniform sampler2D uInput;
uniform sampler2D uAtlas;
uniform float uCols;
uniform float uRows;
uniform float uCharCount;
void main() {
vec2 uv = vUv;
vec2 cell = floor(uv * vec2(uCols, uRows));
vec2 sampleUV = (cell + 0.5) / vec2(uCols, uRows);
vec4 color = texture2D(uInput, sampleUV);
float lum = dot(color.rgb, vec3(0.299, 0.587, 0.114)) * color.a;
float index = floor((1.0 - lum) * (uCharCount - 1.0) + 0.5);
vec2 local = fract(uv * vec2(uCols, uRows));
local = vec2(1.0 - local.x, 1.0 - local.y);
vec2 atlasUV = vec2((index + local.x) / uCharCount, local.y);
vec4 glyph = texture2D(uAtlas, atlasUV);
gl_FragColor = vec4(0.0, 0.0, 0.0, glyph.a);
}
`

export default function asciiLuminancePass(): ScenePass {
const pass: ScenePass = {
type: 'scene',
params: [
{
id: 'charWidth',
type: 'number',
label: 'char width',
default: 12,
min: 4,
max: 64,
step: 1,
},
],
setup,
render,
cleanup,
}

let scene: THREE.Scene | null = null
let camera: THREE.OrthographicCamera | null = null
let material: THREE.ShaderMaterial | null = null
let geometry: THREE.PlaneGeometry | null = null
let atlas: THREE.Texture | null = null

function makeAtlas(size: number) {
const canvas = document.createElement('canvas')
canvas.width = size * asciiChars.length
canvas.height = size
const ctx = canvas.getContext('2d')!
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#000'
ctx.font = `${size}px monospace`
ctx.textBaseline = 'top'
asciiChars.forEach((ch, i) => {
ctx.fillText(ch, i * size, 0)
})
const tex = new THREE.CanvasTexture(canvas)
tex.minFilter = THREE.LinearFilter
tex.magFilter = THREE.NearestFilter
tex.flipY = false
return tex
}

function setup(ctx: PassSetupContext) {
cleanup()
const { size, extraParams } = ctx
const charWidth = (extraParams.charWidth as number) ?? 12
const cols = Math.max(1, Math.floor(size.x / charWidth))
const rows = Math.max(1, Math.floor(size.y / charWidth))

atlas = makeAtlas(64)
geometry = new THREE.PlaneGeometry(2, 2)
material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uInput: { value: null as unknown as THREE.Texture },
uAtlas: { value: atlas },
uCols: { value: cols },
uRows: { value: rows },
uCharCount: { value: asciiChars.length },
},
transparent: true,
blending: THREE.NoBlending,
})
const mesh = new THREE.Mesh(geometry, material)
scene = new THREE.Scene()
scene.add(mesh)
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)
pass.data = { scene, camera }
}

function render(ctx: PassRenderContext) {
if (!scene || !camera || !material) return
material.uniforms.uInput.value = ctx.input
ctx.gl.setRenderTarget(ctx.output)
ctx.gl.setClearColor(0x000000, 0)
ctx.gl.clear(true, true, true)
ctx.gl.render(scene, camera)
ctx.gl.setRenderTarget(null)
}

function cleanup() {
geometry?.dispose()
material?.dispose()
atlas?.dispose()
scene?.clear()
scene = null
camera = null
material = null
geometry = null
atlas = null
}

return pass
}
17 changes: 11 additions & 6 deletions src/effects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import randomPaintFrag from '../shaders/randomPaint.frag'
import rippleFadeFrag from '../shaders/rippleFade.frag'
import gaussianBlurFrag from '../shaders/gaussianBlur.frag'

import { shaderPass, type ShaderPass, type PassParamDef } from './pass'
import { shaderPass, type ShaderPass, type ScenePass, type PassParamDef } from './pass'
import asciiLuminancePass from './asciiLuminance'

export const passRegistry = {
motionBlur: shaderPass(motionBlurFrag),
Expand Down Expand Up @@ -68,7 +69,8 @@ export const passRegistry = {
step: 1,
},
]),
} as const satisfies Record<string, ShaderPass>
asciiLuminance: asciiLuminancePass(),
} as const satisfies Record<string, ShaderPass | ScenePass>

export const effectIndex = {
motionBlur: {
Expand All @@ -87,19 +89,22 @@ export const effectIndex = {
label: 'Blurred Ripple',
passes: [passRegistry.gaussianBlur, passRegistry.rippleFade],
},
} as const satisfies Record<string, { label: string; passes: readonly ShaderPass[] }>
asciiDecay: {
label: 'Ascii Decay',
passes: [passRegistry.rippleFade, passRegistry.asciiLuminance],
},
} as const satisfies Record<string, { label: string; passes: readonly (ShaderPass | ScenePass)[] }>

export type EffectName = keyof typeof effectIndex
export type EffectPass = ShaderPass
export type EffectPass = ShaderPass | ScenePass

export type EffectParamDef = PassParamDef & { passIndex: number }

export function getEffectParamDefs(effect: EffectName): EffectParamDef[] {
const { passes } = effectIndex[effect]
const result: EffectParamDef[] = []
passes.forEach((p, idx) => {
const pass = p as ShaderPass
pass.params?.forEach((param: PassParamDef) => {
p.params?.forEach((param: PassParamDef) => {
result.push({ ...param, passIndex: idx })
})
})
Expand Down
Loading