My Gully, in Pixels
- Category
- Creative Technology
- Year
- 2026
- Role
- Creative & technical lead, AI-assisted engineering
- Links
- Demo
अप्नी गल्ली
A portfolio with a sense of place
A Mumbai street, with working web interfaces behind its storefronts.
Open my projects on a garage computer. Pick up an article at the chai stall. Unwrap two wada pavs to find my contact details.
The design rule: the street handles discovery; the browser handles reading, links, and history. Three.js gives the portfolio a place. Astro and native HTML keep its content accessible, selectable, and indexable.
My contribution: I led the creative and technical direction and defined the system architecture: route coordination, the asset contract between Blender and Three.js, and the rendering pipeline. I configured specialist AI agents for environment design, technical art, and implementation, gave each role shared context and scoped handoffs, and reviewed every change against explicit acceptance criteria. I also drove rendering and asset optimization through a capped device-pixel ratio, cached shadow maps, and targeted texture budgets for miniature props, balancing visual fidelity, readability, and rendering cost.
One camera, many destinations
Selecting Contact halfway through the garage entrance has to hand over control cleanly. The route coordinator releases the active experience, then confirms its navigation token is still current before continuing. A superseded request exits instead of resuming and taking the camera.
Nested Contact views share a single street-camera snapshot, so moving between the paper, stall, and photo album always returns to the same coherent point. Each destination controller owns its input suspension and transition cleanup.
A race you can interrupt
One camera. The newest request.
Request two destinations before resolving their releases. Watch which request gets the camera.
requestToken === latestToken Latest token Destination
Projects
No request yet.
Camera owner
StreetReady for a request.
Destination
Contact
No request yet.
- Street owns the camera. No releases are pending.
The latest request may acquire the camera only after releases complete; an older token is rejected.
Verification: a routing regression test drives the real coordinator with competing requests and asserts that only the latest destination enters.
The newspaper is an interface
Two wada pavs had a demanding job description: arrive wrapped, reveal my contact details, and leave a usable surface behind. Working with AI coding agents, I iterated on both the folds and how I defined success. Early versions passed bounds checks while still reading as a paper boat, which taught me to write visual intent into the acceptance criteria.
Five iterations / One very particular lunch
I was trying to serve lunch.
Getting a newspaper to open convincingly became a lesson in changing the representation—and writing a better brief.
01 / 05
Start with the edges.
A first sheet with raised margins kept the food and print visible. It did not yet conceal a parcel.
Brief, review & timing
I had a surface. I still needed the moment of discovery.
What changed in my thinking
The brief needed to describe the reveal, not just the shape of the paper.
02 / 05
Four flaps. A different problem.
A flat base and four folding panels made each part of the opening easier to direct.
Brief, review & timing
The wrapper could open, but the separate pieces made it read like a constructed box.
What changed in my thinking
More control over the parts did not make them feel like one sheet.
03 / 05
One mesh. Lunch still visible.
One connected sheet replaced the panel construction; its surface opened through procedural deformation.
Brief, review & timing
The continuity helped. Its cup-like starting form still left the food exposed.
What changed in my thinking
A better representation was progress, not an automatic finish line.
04 / 05
Finally, hide the lunch.
The procedural wrapper became a covered parcel, then spread into the newspaper carrying my contact details.
Brief, review & timing
The reveal now made sense. Getting the folds to feel intentional remained its own project.
What changed in my thinking
I needed to direct the whole gesture, including what the camera would reveal.
05 / 05
The fold I chose to keep.
I moved the fold into an editable Blender source: 25 held poses, exported as a two-second GLB animation.
Brief, review & timing
The paper starts opening during the slow overhead camera push. Contact links wait until both are ready.
What changed in my thinking
Change the representation when tuning stops helping. Then check the geometry, timing, and final reading surface.
Assets ready at entry · schematic
Links ready after paper + camera finish. Slow asset loads can extend this timing.
Every version is re-rendered from its saved geometry with the same camera, artwork, and food, so the comparison is like-for-like. Version notes below.
Watch the full 20-second sequence ↗ · GIF version
About this comparison
Each clip renders a saved implementation from a fixed camera, sampling 25 poses with the current artwork and food. Motion is normalized to two seconds so the versions compare directly; the in-app camera timing is shown separately on the final version.
- September 19
707010f· Raised margins, with the food deliberately visible.- September 30
467087a· First base-and-four-panel version.- October 11
8022f78· Continuous procedural sheet.- October 15
420e1a6· Covered procedural parcel.- October 25
- Blender-authored GLB with 25 held poses at 12 poses per second.
The final fold is an art-directed, cloth-assisted sequence, validated with topology and clearance checks alongside visual review.
The final version moves fold authoring into Blender. A cloth-assisted study, sculpted poses, and manual cleanup produce a two-second GLB animation with 25 held poses. The browser plays authored geometry instead of evaluating a radial fold function at runtime, and the newspaper artwork stays swappable without re-exporting the motion.
Timing completes the interaction. The parcel stays closed through the camera approach and opens during the overhead push. Native HTML contact links and food dragging activate only when both the final pose and the camera have settled. Once revealed, the paper stays open on return visits.
Verification: automated checks cover all 25 exported poses and 18 playback lifecycle scenarios, including interrupted entry and return visits, with framing validated across five aspect ratios.
Sharp artwork, correct depth
A pixelated street still needs legible signs. Compositing sharp artwork over the final image would let it show through buildings. The Pixel pipeline instead rebuilds full-resolution depth, then draws selected artwork with depth testing so it stays crisp and correctly occluded.
Original Pixel - 01 / COLOR
Build the pixel image
Pixelated scene → output conversion → color quantization
- 02 / DEPTH
Rebuild occlusion
Clear depth. Render visible geometry with color writes disabled.
- 03 / ARTWORK
Draw the sharp layer
Selected artwork stays crisp and obeys the new depth buffer.
Pipeline schematic. The extra full-resolution passes are the cost of keeping artwork sharp and correctly occluded.
Three.js provides the pixelation and output passes. The project builds on them with color quantization, responsive pixel sizing, a sharp-artwork layer, and automatic fallback to Original mode on failure.
Verification: renderer tests use a mock renderer to assert pass ordering and full state restoration after a failed pass.
AI-assisted work with a definition of done
The creative side of this process is in Two Wada Pavs, a Newspaper, and a City Made of Pixels. On the engineering side, every handoff names three things: the allowed change, the surrounding work to protect, and the evidence required for acceptance. One chai-stall revision shows the pattern:
A real revision / Chai stall
Ground the biscuits.
Preserve the stall.
- Change
- Golden stacks, clear glass, visible headspace.
- Keep
- Jar positions, architecture, signage, newspapers.
- Accept
- Two-angle review, preservation check, GLB reimport.
20 surrounding meshes preserved ↗Inspect the round-trip report ↗
A preservation report confirms the surrounding meshes and jar structure are unchanged. A GLB round-trip report checks bounds, materials, and article metadata. Visual review makes the final call on whether the repair reads correctly.
The workflow assigns distinct review lenses to an architect, a set designer, and a Blender technician. A World Art Director consolidates their findings into one brief, and a single implementer edits the scene, which avoids conflicting writes. I set direction and accept or revise the result.
Inspect the modeling workflow
Modeling workflow
Clear responsibilities, one scene writer, and a reviewable result.
I set the brief
Intent, references, preservation rules, acceptance criteria.
Three review lenses · review only
Architect
Placement, silhouette, sightlines.
Set designer
Composition, props, physical support.
Blender technician
Geometry, transforms, export contract.
World Art Director
Resolve conflicts. Consolidate one bounded brief.
One scene implementer
Apply the agreed changes.
Export + visual checks
Reimport, inspect, test interactions.
I accept or revise
Revisions return to the brief
Each role is defined by a Markdown instruction file with shared project context. New assets get the full review; narrow revisions route only to the specialists they affect.
What this project demonstrates
Graphics fundamentals applied across a full application: coordinate spaces become interfaces, render-pass design preserves spatial logic, and ownership rules keep interaction coherent. The same discipline carries into delegation: clear boundaries, reviewable changes, and explicit acceptance evidence.
Limitations and next steps
- Controlled CPU/GPU profiling on target devices, to quantify the cost of Pixel mode.
- Broader testing on physical phones beyond browser device emulation.
- Closing the remaining input and accessibility gaps, then full release validation.
Technical notesMechanisms, math & tradeoffs
Key numbers at a glance
| Area | Value |
|---|---|
| Wrapper mesh | 4,225-vertex connected grid, 24 position + normal morph targets |
| Wrapper animation | 25 held poses over 2 s (12 poses per second, step interpolation) |
| Contact timing | 1.8 s approach, 4.2 s overhead push, links active after both finish |
| Wrapper validation | 25 poses, 18 lifecycle scenarios, 5 aspect ratios |
| Mesh cleanup | Max edge-length ratio 20.26× → 3.72× (4× regression ceiling) |
| Asset loading | 2 concurrent loaders, priority-sorted, ordered reveal |
| Tap vs. drag | Movement of 6 CSS px or more cancels a tap |
| Color quantization | 16 levels per RGB channel (4,096 base colors) |
System boundaries and the build/runtime split
The scene establishes where you are; HTML carries what you came to read. Holding that boundary keeps an article selectable and scrollable while its newspaper still belongs to the street.
| Layer | Tools | Responsibility |
|---|---|---|
| Authoring | Blender, glTF / GLB | Editable assets, textures, scale, named anchors, export |
| World | JavaScript ES modules, Three.js, WebGL | Scene graph, camera, raycasting, materials, animation, rendering |
| Documents | Astro, MDX, HTML/CSS | Project pages, articles, accessible links and readers |
| Build | Vite, Sharp, Git/GitHub | Bundling, image variants, manifests, versioned changes |
The garage monitor hosts a same-origin project iframe, which separates scene navigation from document navigation: the street controls the camera and monitor placement, and the embedded site owns its content and scrolling. Semantic actions are instrumented with PostHog, so engagement can be measured per destination.

The key module boundaries are stagedLoader.js for arrival and reveal, main.js for route coordination, destination controllers such as contactExperience.js for camera and input ownership, and pixelRenderer.js for the presentation pipeline. Clear boundaries mean a visual bug can be traced to a specific responsibility.
Takeaway: decide which layer owns each concern (place, content, build) before writing features.
Coordinate spaces, picking, and the asset contract
Local space, world space, screen space
Moving a stall should move its paper, camera target, and clickable contact rows together. Hardcoded world coordinates would break on every layout revision.
Instead, the exported model carries named markers such as Contact_PaperAnchor. In a scene graph, each object’s transform is relative to its parent, and composing those transforms gives its world transform. createContactLayout() derives positions, directions, the drag plane, and camera shots from that one anchor.
A local paper point projects to the screen as:
is the model matrix including parent transforms, is the view matrix (the inverse camera transform), and is the projection matrix. Dividing by is the perspective divide. Normalized device coordinates then map to viewport CSS pixels:
screenX = (ndc.x + 1) × width / 2
screenY = (1 − ndc.y) × height / 2Y is flipped because screen Y grows downward. Any canvas offset is added when positioning elements outside the canvas.
The contact rows are defined as rectangles in the newspaper artwork’s UV space. Once the reveal and camera move finish, their corners map onto the final flat paper surface, inherit the anchor transform, and project into HTML link targets. They stay inactive while the folds are moving. The result is a printed advertisement that behaves like a real web page, without rendering the document into WebGL.
Picking is the reverse journey
A pointer position converts to normalized device coordinates; Three.js casts a ray from the camera and intersects scene geometry. The hit then has to resolve to an intended destination, so the picker filters out decorative foreground geometry, transparent sprite areas, and nested model children rather than taking the first intersection.
Input also needs intent: a tap must not fire after a drag or pinch. The picker tracks the pressed target, cancels the tap after 6 CSS pixels of movement, and ignores multi-pointer gestures. Destination controllers suspend street interactions while they own the view.
Takeaway: an asset is an interface contract. Names, scale, orientation, and transform inheritance matter as much as appearance. When the garage was enlarged and a neighboring stall moved, checks against exported markers and geometry kept screens, shutters, and camera framing aligned.
Asynchronous camera ownership and route tokens
A visitor selects Projects, the garage entrance starts, and then they select Contact. If both experiences keep updating the camera, they fight over it.
The camera therefore has exactly one active owner. The route coordinator releases the old experience before another can acquire it, and a monotonically increasing navigation token rejects stale asynchronous continuations.
Simplified from the coordinator:
const request = ++routeToken;
await releasePreviousExperience();
if (request !== routeToken) return;
await enterRequestedExperience();The critical moment is after await: while one request waits, a newer one can arrive. Comparing tokens stops the older request from resuming as if nothing changed. The coordinator repeats this check after every awaited release.
| Moment | Request A: Projects | Request B: Contact |
|---|---|---|
| A begins | Receives token 1; waits for release | — |
| B begins before A resumes | Token 1 becomes stale | Receives token 2 |
| A resumes | Fails token check; stops | Remains current |
| B continues | — | Enters after release and its own check |
The token guards continuations; controllers handle the rest: stopping in-flight tweens, releasing input, and restoring state. Network requests need their own cancellation, such as an AbortSignal.
Nested Contact routes (paper, stall overview, photo album) share one saved street camera position and target. Saving again at each subview would overwrite the return point with a close-up. Reduced motion shortens the transitions but reaches the same final state.
Takeaway: camera animation is a state-management problem. Camera phase, shutter position, lighting, route, and input availability are separate state dimensions that a single isOpen flag cannot represent.
Loading, readiness, failure handling, and resource lifetime
Download order and presentation order are separate concerns. The road should establish the space before shops appear, even if a small skyline file finishes first.
createStagedLoader() runs two concurrent loaders over a priority-sorted job list. Models are prepared while hidden, and a separate sequence reveals road, pavement, lamps, shops, skyline, and details in order. Road and pavement are visual groups extracted from the same GLB.
// Simplified structure.
startTwoWorkers(prioritySortedJobs);
for (const stage of presentationOrder) {
const results = await resultsFor(stage);
await reveal(successfulRoots(results));
}Shop picking enables after the shop reveal, destination choreography waits for full scene readiness, and optional traffic starts last. That gives four distinct states: loaded, visible, interactive, and ready for a close-up.
A failed job records the failure and resolves, so later stages continue. Reduced motion keeps the sequence and drops the fade. The concurrency cap of two is a tunable policy. These loaders are async tasks on the main thread, not Web Workers, so they schedule network and decode work rather than parallelize it.
Resource lifetime is part of the feature
Removing a mesh from the scene does not free its GPU geometry, materials, textures, or render targets, so cleanup follows ownership rules. A temporary fade material is disposed while the shared texture it uses stays owned by the loaded asset.
The photo wall is a good example: it borrows model meshes, creates replacement geometry, materials, and a texture atlas, then restores the borrowed state and releases its own allocations on exit. Generation counters reject async results that arrive after teardown, and abort signals cancel loads where supported.
Takeaway: a feature includes its exit path. Listeners, timers, observers, GPU allocations, and late async results need as much care as the entrance animation.
Render passes, color quantization, and DPR
Pixel mode combines Three.js’s RenderPixelatedPass and OutputPass with a custom color-quantization shader. The design goal is a stylized street where selected signs and artwork stay legible at close range.
Why sharp artwork still needs depth
Drawing a crisp sign over the finished image would make it visible through walls. The renderer reconstructs full-resolution depth first:
Pixelated scene → output conversion → color quantization
Clear depth → visible-geometry depth only → sharp artwork with depth testingThe depth-only pass disables color writes, so it records nearer geometry without touching the composed image, and the sharp pass then respects occlusion. Selected sprites are excluded from the coarse pass so no pixelated duplicate shows behind them. A finally block restores visibility, color-write flags, background, auto-clear, and the shadow-update flag even if a pass throws.
Pixel size and color are separate controls
Block size is specified in CSS pixels and multiplied by device-pixel ratio, so at DPR 2 a 4 px block spans 8 device pixels. Mobile and close Contact views use smaller blocks.
For a normalized color channel with levels, quantization is:
Sixteen levels per channel give 4,096 base colors; blending by strength softens the result between them. Tone mapping, output color-space conversion, and quantization are distinct steps, and their order changes the final image.
Takeaway: a low-resolution look can require extra full-resolution work. Pixel mode adds passes, so performance has to be measured, not assumed. If the pipeline fails to initialize or render, the app falls back to Original mode.
The wrapper: authored motion, runtime state, and validation
Corner bends and separate panels satisfied simple bounds but missed the silhouette I wanted. Continuous procedural versions improved the surface, yet good-looking endpoints hid awkward in-between folds. The breakthrough was changing the representation and the brief together.
Author the shape; play the result
The source is an editable Blender scene. A cloth-assisted study provides the starting motion; sculpted poses and cleanup give full control over the choreography. The export is a connected 4,225-vertex grid with UVs, a closed basis shape, and 24 position-and-normal morph targets. One two-second Unwrap clip uses step interpolation to hold 25 poses at 12 poses per second.
poseIndex = min(24, floor(elapsedSeconds × 12))At runtime, the adapter maps elapsed time to clip time; the browser plays back authored poses with no physics solve. Baked normals keep shading correct for each shape, bounds cover every pose, and shadow and outline updates follow pose changes. The printed front and blank back share geometry and morph weights with separate materials. The newspaper texture can change independently, as long as the contact-row coordinates still match its layout.
Two clocks, one reveal
The camera approaches for 1.8 seconds while the parcel stays closed. The two-second unwrap starts with the 4.2-second overhead push, and links and food dragging wait for both to finish. Leaving before the cue keeps the parcel closed; leaving after it starts lets playback complete. The revealed state and dragged food positions persist across visits until reload. Reduced motion jumps straight to the final pose.
Arrival, animation completion, and interaction readiness are separate states, and a slow or stalled approach frame cannot trigger the reveal early.
Validation
Checks run on all 25 exported poses for finite geometry, topology, concealment, clearance, and a flat contact footprint. Four stretched pin corners initially produced needle-like edges; cleanup cut the maximum edge-length ratio from 20.26× to 3.72×, now guarded by a 4× regression ceiling. Eighteen lifecycle cases cover cue timing, interruption, return visits, and readiness. Pipeline checks confirm that a standard export preserves the saved scene and that swapping the artwork leaves the GLB unchanged.
Geometry checks and visual review cover different failures: topology checks confirm one connected sheet, and visual review catches self-intersection and awkward folds. The art-directed approach trades physical exactness for precise control over the choreography.
Earlier approach: the procedural parcel
The October 15 version used a continuous 72 × 96 grid deformed at runtime by radial compression, angular delays, and separate spread and flatten terms, eased with quintic smootherstep. Normals and bounds were recomputed every frame, and its checks sampled 41 poses. Moving to authored morph targets removed that per-frame cost and gave direct control over every pose.
Takeaway: when parameter tuning stops helping, change the representation. “Passes the geometry checks” and “looks like someone unwrapping lunch” are separate acceptance criteria.
Delegation model
The role documents give every agent the same project context and a distinct review responsibility. It is a development protocol built on general-purpose AI assistants with role-specific instructions. New assets get broad review; narrow revisions involve only the affected specialists. Findings are consolidated before a single implementer touches the scene.
Takeaway: delegate with a bounded brief, one writer, and evidence-based acceptance. I own the direction and the final call.
Visual credits
The hero image was captured from the live city on October 24, 2026. The rendering comparison uses October 17 development captures, and the wrapper timeline re-renders each saved version with a shared camera and current artwork for a like-for-like comparison. Small props in the street include adapted CC BY 4.0 models: LEGO Scarlet Witch by Ramón Ruiz, Art Reference: Utah Teapot by Smallpoly, and Chicago Cubs by Laurance Animations.