Perfectly aligning an interactive CSS3D terminal inside a curved WebGL CRT monitor

Hi everyone,

I recently built an interactive 3D terminal portfolio, and I wanted to share the rendering architecture, specifically how I handled the occlusion between WebGL and CSS3D.

:red_circle: Live Demo: https://matthew-nader.web.app
:laptop: GitHub Repo: GitHub - MatthewNader2/Portfolio ¡ GitHub

The Rendering Problem:
I wanted the terminal to use real, selectable HTML text (via xterm.js) rather than WebGL text, so I set up a dual-renderer (WebGLRenderer + CSS3DRenderer). However, the standard approach (like the css3d_mixed example using a WebGL plane with colorWrite: false to punch a hole in the depth buffer) only works perfectly for flat surfaces.

My CRT monitor model has a curved glass screen and a rounded bezel. If I just placed a flat rectangular CSS3D <div> inside it, the corners of the flat HTML would clip straight through the curved 3D geometry, ruining the illusion and blocking mouse raycasting on the TV frame.

The Solution (Dynamic CSS Clip-Path):
Instead of WebGL depth masking, I wrote a custom occlusion algorithm that dynamically alters the shape of the DOM element itself in real-time to match the 3D model.

  1. Projection: I take the specific curved geometry of the TV screen mesh and project its 3D vertices onto the 2D screen space using the camera’s projection matrix.
  2. Rasterization: These projected triangles are drawn onto an off-screen HTML Canvas to create a binary mask.
  3. Tracing & Simplification: I use the Moore-Neighbor Tracing algorithm to find the exact pixel boundary of the screen shape, and then the Ramer-Douglas-Peucker (RDP) algorithm to reduce those thousands of pixels into a lightweight set of polygon coordinates.
  4. Application: These coordinates are converted to percentages and applied every frame as a clip-path: polygon(...) to the CSS3D container.

The result is that the flat HTML terminal is physically cut to fit perfectly inside the curved 3D bezel from any camera angle, with zero visual overflow.

The Engine (Bonus):
As a fun extra step, the terminal commands aren’t handled by standard JS string splitting. I wrote a custom command compiler in C (using Flex/Bison) and compiled it to WebAssembly via Emscripten to handle the terminal logic and JSON data parsing natively.

I would love to hear your thoughts on this approach to mixing curved WebGL meshes with CSS3D!

4 Likes

Your scene is lovely!

1 Like

I’m not sure about the number of hoops you jumped through to get the punchthru tho?

Can’t you just render the monitors screen mesh into the punchthru alpha to show through to your CSSRenderer behind the WebGL canvas?

the css3d-mixed sample also shows this.. notice at oblique angles, the boxy picture frame properly occludes the CSS content.

The key insight being that the CSSRenderer is placed behind the (webgl canvas with alpha=true)

edit: hahah found my earliest reference to it from 13 years ago!! :

and some potential pitfalls to watch out for:

Also.. as an extension to this.. you can also put another CSS layer on top of everything if you want something with nice transparency / blending but no occlusion… or using the punchthru for occlusion, but limited blending/transparency.

2 Likes

You are totally right about the visual aspect, and the alpha punch-through method is absolutely the way to go if it’s purely a visual occlusion problem.

The ‘hoops’ I jumped through with the custom algorithm weren’t actually for the rendering—they were strictly to solve DOM event routing and Three.js raycasting.

In the classic punch-through setup (CSS behind a WebGL canvas with alpha=true), you run into a massive pointer-event conflict if both layers need to be fully interactive:

  1. The WebGL Canvas Blocker: To interact with the HTML terminal behind the WebGL canvas, the canvas typically needs pointer-events: none. But doing that completely kills the Three.js Raycaster, meaning the user can no longer click or interact with the 3D TV model itself.

  2. The Hitbox Overlap: Even if I used an event-forwarding hack, a flat HTML rectangle behind a curved 3D bezel means the invisible corners of the HTML <div> extend behind the solid 3D bezel. Those hidden corners create dead zones where the DOM intercepts events that should belong to the 3D model.

By calculating the 2D contour and using clip-path, I am physically altering the DOM’s hit-area. Because clip-path natively restricts pointer events in the browser, the interactive HTML hitbox perfectly maps to the curved glass. Clicks on the terminal go to xterm.js, and clicks on the bezel pass straight through the ‘cut’ areas directly to the WebGL canvas and Raycaster.

It definitely took some extra math, but it resulted in pixel-perfect pointer events for both layers without having to write a custom event-forwarding manager!

1 Like

Ok I think I understand now.
You’re using this clip region to route the events specifically to that virtual screen region of the dom.
That is pretty cool.
I guess it might be pretty expensive for a moving camera.. (or is the mask automatically transformed by the CSSRenderer?)

And also computing the mask per pixel is pretty wild.. you could perhaps more easily extract the perimeter of the screen glass geometry directly? First by identifying the perimeter edges at init time (using HalfEdge imported from ConvexHull), and then transforming just the edges to screenspace to generate the clip path?
That’s just nitpicking tho. It sounds like what you have works, so it’s all good. :smiley:

1 Like

This is honestly really clever

The clip path approach is kind of wild but it makes sense. You basically sidestepped the usual flat surface limitation instead of fighting it. The fact that it holds up from different camera angles is what makes it feel legit

Projecting the mesh to screen space and tracing it into a polygon every frame is a pretty heavy pipeline, but the result looks super clean. The RDP step is a nice touch, otherwise that polygon would get out of hand fast

I get what manthrax is saying about using the usual punch through setup, but that still breaks down with curved geometry, so your solution feels more general even if it’s more complex

Also the wasm terminal compiler is a fun flex, didn’t expect that part at all

Only thing I’m wondering is how this scales. Are you caching or throttling the clip path updates or is it recomputed every frame no matter what

Really cool approach overall, feels like one of those ideas people will reuse once they see it done properly

1 Like

Spot on! Routing those pointer events perfectly was the entire motivation.

You bring up a brilliant point about the per-pixel computation. Using HalfEdge to extract the perimeter at init and just projecting those edge vertices to screen-space is an incredibly smart optimization.

I originally went with the rasterization → Moore-Neighbor approach because it felt foolproof for handling complex or concave screen shapes without having to deal with edge-sorting headaches. But you are completely right—for a relatively simple, static curved monitor geometry, extracting the perimeter edges once and just doing the matrix math on camera move would be vastly more performant. I am definitely going to experiment with the ConvexHull approach to save on CPU cycles. Thanks for the tip!

1 Like

Thank you! I’m glad you appreciated the WASM side-quest—writing that lexer and parser was honestly the most fun part of the build.

To answer your question about scaling and performance: you are totally right, running that full pipeline (projection → rasterize → trace → RDP simplify) blindly every single frame at 60fps would absolutely cook the CPU.

To keep it scalable, the clip-path is cached. The recalculation pipeline is strictly tied to camera movement (listening for updates to the camera’s projection matrix / OrbitControls). If the camera is static, the CSS clip-path just sits there doing nothing. It only gets heavy during active rotation.

Even then, tweaking the epsilon value on the Ramer-Douglas-Peucker algorithm helps keep the final polygon array lightweight enough that the browser’s CSS engine doesn’t stutter. But as manthrax mentioned above, there is definitely room to optimize the pipeline even further for moving cameras!

1 Like