API Unavailable
This demo requires Chrome Canary with the
canvas-draw-element flag enabled.
3D Room with Live Web Content
1. HTML elements live inside
<canvas layoutsubtree> elements
2. drawElementImage() captures live DOM into canvas bitmaps
3. Canvas bitmaps become WebGL textures via Three.js CanvasTexture
4. Walk around and see live-updating HTML on 3D surfaces
This pipeline is impossible without HTML-in-Canvas — previously you needed html2canvas or dom-to-image, which are slow, lossy, and cannot update at 60 fps.
A Three.js first-person 3D room where live HTML elements are rendered as textures on surfaces — a monitor dashboard, a styled poster, and a TV playing CSS animations.
Source Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>3D Room with Live Web Content — HTML-in-Canvas</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap"
rel="stylesheet"
/>
<style>
* {
box-sizing: border-box;
}
html {
height: 100%;
}
body,
:host {
display: block;
position: relative;
margin: 0;
width: 100%;
min-height: min(78vh, 820px);
height: 100%;
background: #0a0a0f;
color: #f0f0f0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
overflow: hidden;
}
/* ============================================================
Three.js viewport
============================================================ */
#viewport {
display: block;
width: 100%;
height: 100%;
min-height: min(78vh, 820px);
}
/* ============================================================
Entry overlay
============================================================ */
#overlay {
position: absolute;
inset: 0;
background: rgba(10, 10, 15, 0.92);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
cursor: pointer;
transition: opacity 0.4s;
}
#overlay.hidden {
opacity: 0;
pointer-events: none;
}
#overlay h1 {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 0.35rem;
letter-spacing: -0.02em;
}
.overlay-subtitle {
font-size: 0.85rem;
color: #6a6a80;
margin-bottom: 2rem;
max-width: 400px;
text-align: center;
line-height: 1.5;
}
.key-hints {
display: flex;
gap: 1.25rem;
margin-bottom: 2rem;
font-size: 0.78rem;
color: #6a6a80;
}
.key-hint {
display: flex;
align-items: center;
gap: 0.4rem;
}
.key-hint kbd {
display: inline-block;
padding: 0.15rem 0.45rem;
background: #1a1a30;
border: 1px solid #333355;
border-radius: 4px;
font-family: "Inter", sans-serif;
font-size: 0.7rem;
color: #8a8aaf;
font-weight: 600;
}
.enter-btn {
padding: 0.7rem 1.8rem;
background: #6c41f0;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
margin-bottom: 2.5rem;
}
.enter-btn:hover {
background: #7b52ff;
}
.technique-info {
max-width: 420px;
padding: 1rem 1.25rem;
background: #14141f;
border: 1px solid #282840;
border-radius: 10px;
font-size: 0.72rem;
color: #6a6a80;
line-height: 1.55;
}
.technique-info p {
margin-bottom: 0.3rem;
}
.technique-info p:last-child {
margin-bottom: 0;
}
.technique-info strong {
color: #8a8aaf;
}
.technique-info .hl {
color: #00e5b9;
}
.technique-info code {
color: #6c41f0;
font-family: "JetBrains Mono", monospace;
font-size: 0.68rem;
}
.technique-info .note {
color: #3a3a50;
font-size: 0.65rem;
margin-top: 0.4rem;
}
/* ============================================================
Crosshair
============================================================ */
#crosshair {
position: absolute;
top: 50%;
left: 50%;
width: 4px;
height: 4px;
margin: -2px 0 0 -2px;
background: rgba(255, 255, 255, 0.45);
border-radius: 50%;
z-index: 5;
pointer-events: none;
display: none;
}
#crosshair.visible {
display: block;
}
/* ============================================================
Interaction hint (form focus indicator)
============================================================ */
#interaction-hint {
position: absolute;
bottom: 1.25rem;
left: 50%;
transform: translateX(-50%);
padding: 0.5rem 1rem;
background: rgba(15, 15, 22, 0.9);
border: 1px solid rgba(108, 65, 240, 0.5);
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
color: #a78bfa;
z-index: 8;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}
#interaction-hint.visible {
opacity: 1;
}
/* ============================================================
Off-screen staging canvases
============================================================ */
/*
* Staging canvases hold the HTML content that becomes the
* texture for each 3D surface. They MUST stay laid out and
* visually painted by the browser — otherwise their
* layoutsubtree children have no cached paint record and
* drawElementImage either throws "No cached paint record for
* element" or silently produces empty pixels.
*
* Chrome skips painting for `display: none`, `visibility: hidden`,
* `opacity: 0`, and (empirically) `left: -9999px`. The reliable
* way to hide an element while keeping it painted is the
* legacy `clip` rect-cropping idiom: position it in viewport,
* give it real dimensions, then clip it to a 0x0 area so it's
* laid out, painted, and visually invisible.
*/
/*
* Staging canvases are wrapped in a 1×1 visible container at
* the corner of the page. The staging canvases themselves are
* full-size (800×500 etc.) and overflow the container, but
* `overflow: hidden` clips them visually to the 1×1 area while
* the browser still lays them out and paints them — which
* generates the cached paint records that drawElementImage
* needs.
*
* We tried `display: none`, `visibility: hidden`, `opacity: 0`,
* `left: -9999px`, and `clip-path: inset(50%)` first. Chrome
* skips painting for all of those, which leaves the layoutsubtree
* children with no cached paint record and silently produces
* blank pixels.
*
* Each staging canvas is `position: absolute` at the container's
* (0, 0) so they all stack at the same visible 1×1 spot. With
* default static layout the canvases would line-wrap and stack
* vertically (1650+ px tall), pushing the second and third ones
* far below the visible region — and Chrome would silently skip
* painting their layoutsubtree children, just like it does for
* `left: -9999px`.
*/
.staging-container {
position: absolute;
right: 0;
bottom: 0;
width: 1px;
height: 1px;
overflow: hidden;
pointer-events: none;
}
.staging {
position: absolute;
top: 0;
left: 0;
}
#monitor-canvas {
width: 800px;
height: 500px;
}
#poster-canvas {
width: 500px;
height: 700px;
}
#tv-canvas {
width: 800px;
height: 450px;
}
/* ============================================================
Monitor dashboard content
============================================================ */
#monitor-content {
width: 800px;
height: 500px;
background: linear-gradient(160deg, #0d1117 0%, #161b22 100%);
color: #e6edf3;
font-family: "Inter", sans-serif;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.dash-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #30363d;
padding-bottom: 0.75rem;
}
.dash-title {
font-size: 1.15rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.5rem;
}
.dash-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #00e5b9;
display: inline-block;
}
.clock {
font-family: "JetBrains Mono", monospace;
font-size: 1.5rem;
font-weight: 600;
color: #00e5b9;
}
.dash-metrics {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.metric {
display: flex;
align-items: center;
gap: 0.75rem;
}
.metric-label {
width: 72px;
font-size: 0.8rem;
color: #8b949e;
font-weight: 500;
}
.metric-bar {
flex: 1;
height: 8px;
background: #21262d;
border-radius: 4px;
overflow: hidden;
}
.metric-fill {
height: 100%;
border-radius: 4px;
transition: width 0.6s ease;
}
.cpu-fill {
background: linear-gradient(90deg, #6c41f0, #a78bfa);
}
.mem-fill {
background: linear-gradient(90deg, #00e5b9, #34d399);
}
.net-fill {
background: linear-gradient(90deg, #f0416c, #fb7185);
}
.disk-fill {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.metric-val {
width: 48px;
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
color: #e6edf3;
text-align: right;
}
.dash-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
color: #8b949e;
padding: 0.5rem 0.75rem;
background: #0d1117;
border: 1px solid #21262d;
border-radius: 6px;
}
.status-led {
width: 6px;
height: 6px;
border-radius: 50%;
background: #00e5b9;
}
.dash-logs {
flex: 1;
background: #0d1117;
border: 1px solid #21262d;
border-radius: 6px;
padding: 0.6rem 0.75rem;
font-family: "JetBrains Mono", monospace;
font-size: 0.68rem;
color: #8b949e;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.log-entry {
line-height: 1.5;
}
.log-time {
color: #484f58;
}
.log-ok {
color: #00e5b9;
}
/* ============================================================
Poster content
============================================================ */
/* ============================================================
Sign-up form (rendered onto the left wall as a poster)
============================================================ */
#poster-content {
width: 500px;
height: 700px;
background: linear-gradient(160deg, #14142a 0%, #0a0a1a 60%, #0a1a2e 100%);
color: #f0f0f0;
font-family: "Inter", system-ui, sans-serif;
padding: 3rem 2.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
position: relative;
overflow: hidden;
}
#poster-content::before {
content: "";
position: absolute;
top: -120px;
right: -120px;
width: 320px;
height: 320px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(108, 65, 240, 0.25),
transparent 70%
);
}
#poster-content::after {
content: "";
position: absolute;
bottom: -100px;
left: -100px;
width: 280px;
height: 280px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(0, 229, 185, 0.18),
transparent 70%
);
}
.poster-form-header {
position: relative;
z-index: 1;
}
.poster-form-eyebrow {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #a78bfa;
margin-bottom: 0.4rem;
}
.poster-form-title {
font-size: 2.2rem;
font-weight: 800;
line-height: 1.1;
background: linear-gradient(135deg, #6c41f0 0%, #00e5b9 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
.poster-form-subtitle {
font-size: 0.95rem;
color: #8a8aaf;
line-height: 1.5;
margin-top: 0.5rem;
max-width: 32ch;
}
.poster-form {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.85rem;
margin-top: 1rem;
}
.poster-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.poster-field label {
font-size: 0.7rem;
font-weight: 600;
color: #6a6a80;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.poster-field input[type="text"],
.poster-field input[type="email"],
.poster-field select {
padding: 0.7rem 0.9rem;
font-family: inherit;
font-size: 0.95rem;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #f0f0f0;
outline: none;
appearance: none;
width: 100%;
}
.poster-field input[type="text"]:focus,
.poster-field input[type="email"]:focus,
.poster-field select:focus {
border-color: rgba(108, 65, 240, 0.6);
background: rgba(0, 0, 0, 0.55);
}
.poster-field select {
cursor: pointer;
background-image:
linear-gradient(45deg, transparent 50%, #6a6a80 50%),
linear-gradient(135deg, #6a6a80 50%, transparent 50%);
background-position:
calc(100% - 16px) 50%,
calc(100% - 11px) 50%;
background-size: 5px 5px;
background-repeat: no-repeat;
padding-right: 2rem;
}
.poster-checkbox-row {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
color: #a0a0b8;
cursor: pointer;
}
.poster-checkbox-row input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #00e5b9;
cursor: pointer;
margin: 0;
}
.poster-submit {
margin-top: 0.5rem;
padding: 0.85rem 1.25rem;
font-family: inherit;
font-size: 1rem;
font-weight: 700;
background: linear-gradient(135deg, #6c41f0, #8a52ff);
color: #fff;
border: none;
border-radius: 8px;
text-align: center;
cursor: pointer;
box-shadow: 0 12px 30px -10px rgba(108, 65, 240, 0.6);
}
.poster-submit:hover {
background: linear-gradient(135deg, #7d52ff, #9b66ff);
}
.poster-fineprint {
position: relative;
z-index: 1;
margin-top: auto;
font-size: 0.7rem;
color: #4a4a60;
text-align: center;
line-height: 1.4;
}
/* ============================================================
TV content — CSS animation showcase
============================================================ */
#tv-content {
width: 800px;
height: 450px;
background: #050510;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.tv-gradient {
position: absolute;
inset: 0;
background: linear-gradient(
45deg,
#0f0c29,
#302b63,
#24243e,
#0f0c29
);
background-size: 400% 400%;
animation: tvGradient 8s ease infinite;
}
@keyframes tvGradient {
0% {
background-position: 0% 50%;
}
25% {
background-position: 100% 0%;
}
50% {
background-position: 100% 100%;
}
75% {
background-position: 0% 100%;
}
100% {
background-position: 0% 50%;
}
}
.tv-orb {
position: absolute;
border-radius: 50%;
opacity: 0.6;
}
.tv-orb-1 {
width: 200px;
height: 200px;
background: radial-gradient(circle, #6c41f0, transparent 70%);
animation: orb1 7s ease-in-out infinite;
}
.tv-orb-2 {
width: 160px;
height: 160px;
background: radial-gradient(circle, #00e5b9, transparent 70%);
animation: orb2 9s ease-in-out infinite;
}
.tv-orb-3 {
width: 180px;
height: 180px;
background: radial-gradient(circle, #f0416c, transparent 70%);
animation: orb3 11s ease-in-out infinite;
}
@keyframes orb1 {
0%,
100% {
transform: translate(-100px, -50px);
}
33% {
transform: translate(250px, 100px);
}
66% {
transform: translate(80px, -80px);
}
}
@keyframes orb2 {
0%,
100% {
transform: translate(300px, 120px);
}
33% {
transform: translate(-30px, -40px);
}
66% {
transform: translate(350px, 40px);
}
}
@keyframes orb3 {
0%,
100% {
transform: translate(120px, 200px);
}
33% {
transform: translate(400px, -30px);
}
66% {
transform: translate(-60px, 130px);
}
}
.tv-center {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.tv-live-badge {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: "JetBrains Mono", monospace;
font-size: 0.75rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.25em;
text-transform: uppercase;
}
.tv-live-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #f0416c;
animation: livePulse 1.5s ease-in-out infinite;
}
@keyframes livePulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.tv-time {
font-family: "JetBrains Mono", monospace;
font-size: 3.5rem;
font-weight: 700;
color: #fff;
text-shadow: 0 0 40px rgba(108, 65, 240, 0.5),
0 0 80px rgba(108, 65, 240, 0.2);
letter-spacing: 0.05em;
}
.tv-date {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.35);
font-weight: 500;
letter-spacing: 0.1em;
}
.tv-bars {
display: flex;
align-items: flex-end;
gap: 4px;
margin-top: 1.25rem;
height: 30px;
}
.tv-bar {
width: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
animation: barBounce 1.2s ease-in-out infinite;
}
.tv-bar:nth-child(1) {
animation-delay: 0s;
}
.tv-bar:nth-child(2) {
animation-delay: 0.1s;
}
.tv-bar:nth-child(3) {
animation-delay: 0.2s;
}
.tv-bar:nth-child(4) {
animation-delay: 0.3s;
}
.tv-bar:nth-child(5) {
animation-delay: 0.4s;
}
.tv-bar:nth-child(6) {
animation-delay: 0.5s;
}
.tv-bar:nth-child(7) {
animation-delay: 0.6s;
}
.tv-bar:nth-child(8) {
animation-delay: 0.7s;
}
.tv-bar:nth-child(9) {
animation-delay: 0.15s;
}
.tv-bar:nth-child(10) {
animation-delay: 0.35s;
}
.tv-bar:nth-child(11) {
animation-delay: 0.55s;
}
.tv-bar:nth-child(12) {
animation-delay: 0.25s;
}
@keyframes barBounce {
0%,
100% {
height: 6px;
}
50% {
height: 28px;
}
}
</style>
</head>
<body>
<!-- ============================================================
Three.js viewport
============================================================ -->
<canvas id="viewport"></canvas>
<!-- ============================================================
Entry overlay
============================================================ -->
<div id="overlay">
<h1>3D Room with Live Web Content</h1>
<p class="overlay-subtitle">
Walk through a room where HTML elements are rendered as live textures on
3D surfaces using the HTML-in-Canvas API
</p>
<div class="key-hints">
<div class="key-hint"><kbd>W A S D</kbd> / <kbd>←↑↓→</kbd> Move</div>
<div class="key-hint"><kbd>Mouse</kbd> Look</div>
<div class="key-hint"><kbd>Click</kbd> a form field to type</div>
<div class="key-hint"><kbd>ESC</kbd> Exit</div>
</div>
<button class="enter-btn">Click to Enter</button>
<div class="technique-info">
<p>
<strong>1.</strong> HTML elements live inside
<code><canvas layoutsubtree></code> elements
</p>
<p>
<strong>2.</strong>
<span class="hl">drawElementImage()</span> captures live DOM into
canvas bitmaps
</p>
<p>
<strong>3.</strong> Canvas bitmaps become
<span class="hl">WebGL textures</span> via Three.js CanvasTexture
</p>
<p>
<strong>4.</strong> Walk around and see
<span class="hl">live-updating HTML</span> on 3D surfaces
</p>
<p class="note">
This pipeline is impossible without HTML-in-Canvas — previously you
needed html2canvas or dom-to-image, which are slow, lossy, and cannot
update at 60 fps.
</p>
</div>
</div>
<!-- ============================================================
Crosshair (visible during pointer lock)
============================================================ -->
<div id="crosshair"></div>
<!-- ============================================================
Interaction hint (visible while typing into a focused field)
============================================================ -->
<div id="interaction-hint">Typing into form — press Esc to release</div>
<!-- ============================================================
Staging canvases — HTML content rendered here, then read into
CanvasTextures and used as 3D surface materials. Wrapped in a
1x1 visible container so the browser paints them (see CSS).
============================================================ -->
<div class="staging-container">
<!-- Monitor: live dashboard -->
<canvas
id="monitor-canvas"
class="staging"
width="800"
height="500"
layoutsubtree
>
<div id="monitor-content">
<div class="dash-header">
<div class="dash-title">
<span class="dash-dot"></span>
System Dashboard
</div>
<div id="clock" class="clock">00:00:00</div>
</div>
<div class="dash-metrics">
<div class="metric">
<div class="metric-label">CPU</div>
<div class="metric-bar">
<div class="metric-fill cpu-fill" id="cpu-bar" style="width: 64%"></div>
</div>
<div class="metric-val" id="cpu-val">64%</div>
</div>
<div class="metric">
<div class="metric-label">Memory</div>
<div class="metric-bar">
<div class="metric-fill mem-fill" id="mem-bar" style="width: 42%"></div>
</div>
<div class="metric-val" id="mem-val">42%</div>
</div>
<div class="metric">
<div class="metric-label">Network</div>
<div class="metric-bar">
<div class="metric-fill net-fill" id="net-bar" style="width: 78%"></div>
</div>
<div class="metric-val" id="net-val">78%</div>
</div>
<div class="metric">
<div class="metric-label">Disk I/O</div>
<div class="metric-bar">
<div
class="metric-fill disk-fill"
id="disk-bar"
style="width: 31%"
></div>
</div>
<div class="metric-val" id="disk-val">31%</div>
</div>
</div>
<div class="dash-status">
<span class="status-led"></span>
Connected — All systems nominal
</div>
<div class="dash-logs">
<div class="log-entry" id="log-1">
<span class="log-time">[--:--:--]</span>
<span class="log-ok">OK</span> Canvas texture sync complete
</div>
<div class="log-entry" id="log-2">
<span class="log-time">[--:--:--]</span>
<span class="log-ok">OK</span> WebGL context acquired
</div>
<div class="log-entry" id="log-3">
<span class="log-time">[--:--:--]</span>
<span class="log-ok">OK</span> All surfaces rendering
</div>
<div class="log-entry" id="log-4">
<span class="log-time">[--:--:--]</span>
<span class="log-ok">OK</span> Awaiting input…
</div>
</div>
</div>
</canvas>
<!-- Poster: styled typographic poster -->
<canvas
id="poster-canvas"
class="staging"
width="500"
height="700"
layoutsubtree
>
<div id="poster-content">
<div class="poster-form-header">
<div class="poster-form-eyebrow">Contact En Dash</div>
<h2 class="poster-form-title">Drop us<br />a note</h2>
<p class="poster-form-subtitle">
This form is one real HTML element drawn onto the wall
via drawElementImage. Aim at a field and click to type.
</p>
</div>
<form class="poster-form" id="poster-form" onsubmit="return false">
<div class="poster-field">
<label for="poster-name">Your name</label>
<input
type="text"
id="poster-name"
value="Jane Doe"
autocomplete="off"
/>
</div>
<div class="poster-field">
<label for="poster-email">Email</label>
<input
type="email"
id="poster-email"
value="jane@example.com"
autocomplete="off"
/>
</div>
<div class="poster-field">
<label for="poster-topic">What's on your mind?</label>
<select id="poster-topic">
<option>HTML-in-Canvas spec feedback</option>
<option>A demo idea</option>
<option>Consulting inquiry</option>
<option>Just saying hi</option>
</select>
</div>
<button type="submit" class="poster-submit" id="poster-submit">
Drop us a note →
</button>
</form>
<div class="poster-fineprint">
html-in-canvas.dev · No spam, no third-party tracking
</div>
</div>
</canvas>
<!-- TV: CSS animation showcase -->
<canvas
id="tv-canvas"
class="staging"
width="800"
height="450"
layoutsubtree
>
<div id="tv-content">
<div class="tv-gradient"></div>
<div class="tv-orb tv-orb-1"></div>
<div class="tv-orb tv-orb-2"></div>
<div class="tv-orb tv-orb-3"></div>
<div class="tv-center">
<div class="tv-live-badge">
<span class="tv-live-dot"></span>
Live CSS Animation
</div>
<div class="tv-time" id="tv-time">00:00</div>
<div class="tv-date" id="tv-date">Thursday, April 10</div>
<div class="tv-bars">
<div class="tv-bar"></div>
<div class="tv-bar"></div>
<div class="tv-bar"></div>
<div class="tv-bar"></div>
<div class="tv-bar"></div>
<div class="tv-bar"></div>
<div class="tv-bar"></div>
<div class="tv-bar"></div>
<div class="tv-bar"></div>
<div class="tv-bar"></div>
<div class="tv-bar"></div>
<div class="tv-bar"></div>
</div>
</div>
</div>
</canvas>
</div><!-- /staging-container -->
<!-- ============================================================
Three.js (loaded via CDN, no build step).
Pinned to 0.158.0 because Three.js dropped the UMD bundle
(build/three.min.js) in 0.162.0 in favor of ES modules.
============================================================ -->
<script src="https://unpkg.com/three@0.158.0/build/three.min.js"></script>
<script>
// Resolve the script's root: a ShadowRoot when mounted via shadow
// DOM, or `document` when this file is served standalone.
const root = window.__demoRoot ?? document;
const $ = (id) => root.getElementById(id);
/* ==============================================================
DOM references
============================================================== */
const viewport = $("viewport");
const overlay = $("overlay");
const crosshair = $("crosshair");
const monitorCanvas = $("monitor-canvas");
const posterCanvas = $("poster-canvas");
const tvCanvas = $("tv-canvas");
const monitorContent = $("monitor-content");
const posterContent = $("poster-content");
const tvContent = $("tv-content");
const clockEl = $("clock");
const cpuBar = $("cpu-bar");
const memBar = $("mem-bar");
const netBar = $("net-bar");
const diskBar = $("disk-bar");
const cpuVal = $("cpu-val");
const memVal = $("mem-val");
const netVal = $("net-val");
const diskVal = $("disk-val");
const tvTime = $("tv-time");
const tvDate = $("tv-date");
const logEntries = [
$("log-1"),
$("log-2"),
$("log-3"),
$("log-4"),
];
/* ==============================================================
2D contexts for staging canvases
============================================================== */
const monitorCtx = monitorCanvas.getContext("2d");
const posterCtx = posterCanvas.getContext("2d");
const tvCtx = tvCanvas.getContext("2d");
/* ==============================================================
Paint callbacks — capture HTML into canvas bitmaps
============================================================== */
let monitorDirty = true;
let posterDirty = true;
let tvDirty = true;
monitorCanvas.onpaint = () => {
monitorCtx.clearRect(0, 0, monitorCanvas.width, monitorCanvas.height);
monitorCtx.drawElementImage(monitorContent, 0, 0);
monitorDirty = true;
};
posterCanvas.onpaint = () => {
posterCtx.clearRect(0, 0, posterCanvas.width, posterCanvas.height);
posterCtx.drawElementImage(posterContent, 0, 0);
posterDirty = true;
};
tvCanvas.onpaint = () => {
tvCtx.clearRect(0, 0, tvCanvas.width, tvCanvas.height);
tvCtx.drawElementImage(tvContent, 0, 0);
tvDirty = true;
};
/* Trigger initial paints */
monitorCanvas.requestPaint();
posterCanvas.requestPaint();
tvCanvas.requestPaint();
/* ==============================================================
Three.js scene
============================================================== */
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05050a);
// Use the viewport canvas's actual CSS size — not window.* —
// so the renderer matches the stage in both standalone and
// shadow-mounted contexts.
function viewportSize() {
const w = viewport.clientWidth || 1;
const h = viewport.clientHeight || 1;
return { w, h };
}
const initial = viewportSize();
// Near plane is small so the monitor / poster / TV planes don't
// get clipped when the user walks right up to them.
const camera = new THREE.PerspectiveCamera(
70,
initial.w / initial.h,
0.01,
100,
);
camera.position.set(0, 1.7, 2.5);
const renderer = new THREE.WebGLRenderer({
canvas: viewport,
antialias: true,
});
// Pass `false` to setSize so it doesn't overwrite the CSS size
// we set on the viewport — we want CSS to drive layout.
renderer.setSize(initial.w, initial.h, false);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.25;
/* ==============================================================
Room geometry
============================================================== */
const ROOM_W = 10;
const ROOM_H = 3.5;
const ROOM_D = 8;
const HALF_W = ROOM_W / 2;
const HALF_D = ROOM_D / 2;
/* Floor */
const floorGeo = new THREE.PlaneGeometry(ROOM_W, ROOM_D);
const floorMat = new THREE.MeshStandardMaterial({
color: 0x3a3a55,
roughness: 0.7,
metalness: 0.15,
});
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = 0;
scene.add(floor);
/* Rug */
const rugGeo = new THREE.PlaneGeometry(4.5, 3.5);
const rugMat = new THREE.MeshStandardMaterial({
color: 0x4a3066,
roughness: 0.95,
});
const rug = new THREE.Mesh(rugGeo, rugMat);
rug.rotation.x = -Math.PI / 2;
rug.position.set(0, 0.005, 0.5);
scene.add(rug);
/* Ceiling */
const ceilingGeo = new THREE.PlaneGeometry(ROOM_W, ROOM_D);
const ceilingMat = new THREE.MeshStandardMaterial({
color: 0x252535,
roughness: 0.95,
});
const ceiling = new THREE.Mesh(ceilingGeo, ceilingMat);
ceiling.rotation.x = Math.PI / 2;
ceiling.position.y = ROOM_H;
scene.add(ceiling);
/* Walls — readable mid-tone with a slight purple cast so they
have visible contrast against the near-black scene background */
const wallMat = new THREE.MeshStandardMaterial({
color: 0x363650,
roughness: 0.85,
metalness: 0.05,
});
const backWall = new THREE.Mesh(
new THREE.PlaneGeometry(ROOM_W, ROOM_H),
wallMat,
);
backWall.position.set(0, ROOM_H / 2, -HALF_D);
scene.add(backWall);
const frontWall = new THREE.Mesh(
new THREE.PlaneGeometry(ROOM_W, ROOM_H),
wallMat.clone(),
);
frontWall.position.set(0, ROOM_H / 2, HALF_D);
frontWall.rotation.y = Math.PI;
scene.add(frontWall);
const leftWall = new THREE.Mesh(
new THREE.PlaneGeometry(ROOM_D, ROOM_H),
wallMat.clone(),
);
leftWall.position.set(-HALF_W, ROOM_H / 2, 0);
leftWall.rotation.y = Math.PI / 2;
scene.add(leftWall);
const rightWall = new THREE.Mesh(
new THREE.PlaneGeometry(ROOM_D, ROOM_H),
wallMat.clone(),
);
rightWall.position.set(HALF_W, ROOM_H / 2, 0);
rightWall.rotation.y = -Math.PI / 2;
scene.add(rightWall);
/* Baseboards */
const baseMat = new THREE.MeshStandardMaterial({ color: 0x222233 });
function addBaseboard(width, x, z, rotY) {
const geo = new THREE.BoxGeometry(width, 0.1, 0.03);
const mesh = new THREE.Mesh(geo, baseMat);
mesh.position.set(x, 0.05, z);
mesh.rotation.y = rotY;
scene.add(mesh);
}
addBaseboard(ROOM_W, 0, -HALF_D + 0.015, 0);
addBaseboard(ROOM_W, 0, HALF_D - 0.015, 0);
addBaseboard(ROOM_D, -HALF_W + 0.015, 0, Math.PI / 2);
addBaseboard(ROOM_D, HALF_W - 0.015, 0, Math.PI / 2);
/* ==============================================================
Desk (under monitor)
============================================================== */
const deskMat = new THREE.MeshStandardMaterial({
color: 0x2a2a3e,
roughness: 0.6,
metalness: 0.3,
});
/* Desktop surface */
const deskTop = new THREE.Mesh(
new THREE.BoxGeometry(3, 0.08, 0.9),
deskMat,
);
deskTop.position.set(0, 0.8, -3.55);
scene.add(deskTop);
/* Desk legs */
const legGeo = new THREE.BoxGeometry(0.06, 0.8, 0.06);
const legMat = new THREE.MeshStandardMaterial({ color: 0x222233 });
const legPositions = [
[-1.4, 0.4, -3.9],
[1.4, 0.4, -3.9],
[-1.4, 0.4, -3.2],
[1.4, 0.4, -3.2],
];
for (const pos of legPositions) {
const leg = new THREE.Mesh(legGeo, legMat);
leg.position.set(pos[0], pos[1], pos[2]);
scene.add(leg);
}
/* ==============================================================
Content surfaces with HTML-in-Canvas textures.
The dance: each <canvas layoutsubtree> staging canvas hosts a
DOM tree that drawElementImage paints onto its bitmap. We
can't hand that bitmap directly to Three.js's CanvasTexture
pipeline — when we do, the texture sticks at its first-frame
contents because Three.js's upload path doesn't see the
layoutsubtree-driven repaint as a content change.
The workaround is to copy the staging bitmap into a regular
intermediate canvas via drawImage each frame, and use THAT
as the CanvasTexture source. Three.js sees a regular canvas
and re-uploads on `needsUpdate = true` like usual.
============================================================== */
function makeStagingTexture(srcCanvas) {
// Mirror canvas: a regular HTMLCanvas we copy the layoutsubtree
// canvas's pixels into each frame via drawImage. Three.js's
// CanvasTexture upload pipeline works reliably with regular
// canvases, but stalls when handed a `<canvas layoutsubtree>`
// directly — the texture sticks at its first-frame contents.
// The intermediate copy decouples the two.
const mirror = document.createElement('canvas');
mirror.width = srcCanvas.width;
mirror.height = srcCanvas.height;
const mctx = mirror.getContext('2d');
const tex = new THREE.CanvasTexture(mirror);
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.generateMipmaps = false;
tex.colorSpace = THREE.SRGBColorSpace;
return {
tex,
sync() {
// Copy current pixels from the layoutsubtree staging
// canvas into the regular mirror canvas, then mark the
// texture dirty so Three.js's normal upload path runs.
mctx.clearRect(0, 0, mirror.width, mirror.height);
mctx.drawImage(srcCanvas, 0, 0);
tex.needsUpdate = true;
},
};
}
const monitorStaging = makeStagingTexture(monitorCanvas);
const posterStaging = makeStagingTexture(posterCanvas);
const tvStaging = makeStagingTexture(tvCanvas);
const monitorTexture = monitorStaging.tex;
const posterTexture = posterStaging.tex;
const tvTexture = tvStaging.tex;
/* Monitor bezel — sits flat against the back wall */
const bezelMat = new THREE.MeshStandardMaterial({
color: 0x1a1a25,
roughness: 0.3,
metalness: 0.8,
});
const monitorBezel = new THREE.Mesh(
new THREE.BoxGeometry(2.55, 1.65, 0.06),
bezelMat,
);
monitorBezel.position.set(0, 1.85, -3.97);
scene.add(monitorBezel);
/* Monitor screen — positioned IN FRONT of the bezel's front
* face (which is at z = -3.97 + 0.03 = -3.94) so it isn't
* occluded by the bezel box. */
const monitorMesh = new THREE.Mesh(
new THREE.PlaneGeometry(2.4, 1.5),
new THREE.MeshBasicMaterial({ map: monitorTexture }),
);
monitorMesh.position.set(0, 1.85, -3.93);
scene.add(monitorMesh);
/* Monitor stand */
const standMesh = new THREE.Mesh(
new THREE.BoxGeometry(0.15, 0.2, 0.15),
bezelMat,
);
standMesh.position.set(0, 0.94, -3.7);
scene.add(standMesh);
const standBase = new THREE.Mesh(
new THREE.BoxGeometry(0.5, 0.04, 0.3),
bezelMat,
);
standBase.position.set(0, 0.84, -3.6);
scene.add(standBase);
/* --- Poster (left wall) --- */
/* Frame first; poster mesh in front of the frame's interior face */
const frameMat = new THREE.MeshStandardMaterial({
color: 0x2a2a3e,
roughness: 0.5,
metalness: 0.5,
});
const posterFrame = new THREE.Mesh(
new THREE.BoxGeometry(0.04, 2.1, 1.5),
frameMat,
);
posterFrame.position.set(-4.97, 1.8, 0);
scene.add(posterFrame);
const posterMesh = new THREE.Mesh(
new THREE.PlaneGeometry(1.4, 2.0),
new THREE.MeshBasicMaterial({ map: posterTexture }),
);
posterMesh.position.set(-4.93, 1.8, 0);
posterMesh.rotation.y = Math.PI / 2;
scene.add(posterMesh);
/* --- TV (right wall) --- */
/* Frame first; TV mesh in front of the frame's interior face */
const tvFrame = new THREE.Mesh(
new THREE.BoxGeometry(0.06, 1.7, 2.95),
bezelMat.clone(),
);
tvFrame.position.set(4.97, 2.1, -1);
scene.add(tvFrame);
const tvMesh = new THREE.Mesh(
new THREE.PlaneGeometry(2.8, 1.575),
new THREE.MeshBasicMaterial({ map: tvTexture }),
);
tvMesh.position.set(4.93, 2.1, -1);
tvMesh.rotation.y = -Math.PI / 2;
scene.add(tvMesh);
/* ==============================================================
Lighting — the room reads as moody-but-legible. Bright enough
that surfaces are visible from any angle, with colored accent
lights from each display tinting the surrounding walls.
============================================================== */
scene.add(new THREE.AmbientLight(0xa0a0c0, 1.2));
/* Warm ceiling fill */
const ceilingLight = new THREE.PointLight(0xffe4c4, 1.4, 22);
ceilingLight.position.set(0, 3.3, 0);
scene.add(ceilingLight);
/* Hemisphere light for natural sky/ground bounce */
const hemi = new THREE.HemisphereLight(0xa0b0ff, 0x303040, 1.0);
scene.add(hemi);
/* Monitor glow — cool blue wash on desk and nearby walls */
const monitorLight = new THREE.PointLight(0x4a9eff, 1.4, 7);
monitorLight.position.set(0, 1.8, -3.0);
scene.add(monitorLight);
/* TV glow — purple tint on right side of room */
const tvLight = new THREE.PointLight(0x6c41f0, 1.1, 7);
tvLight.position.set(4.0, 2.1, -1);
scene.add(tvLight);
/* Poster accent — teal highlight on left wall area */
const posterLight = new THREE.PointLight(0x00e5b9, 0.9, 6);
posterLight.position.set(-4.0, 1.8, 0);
scene.add(posterLight);
/* ==============================================================
First-person controls (Pointer Lock + WASD / arrows)
============================================================== */
let isLocked = false;
let interactiveTarget = null; // currently-focused form field
const euler = new THREE.Euler(0, 0, 0, "YXZ");
const moveSpeed = 3.0;
const lookSpeed = 0.002;
const keys = {};
// Arrow keys ALWAYS drive movement. WASD drives movement only
// when no form field is focused — when the user is typing into
// the poster form we let W/A/S/D (and every other printable
// key) flow through to the input unchanged so they can type
// without walking. The user then moves around with the arrow
// keys while still typing if they want.
const MOVEMENT_KEYS = new Set([
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'KeyW', 'KeyA', 'KeyS', 'KeyD',
]);
function isMovementKey(code) {
if (!MOVEMENT_KEYS.has(code)) return false;
if (!interactiveTarget) return true;
// Typing mode: arrows still move, WASD goes to the input.
return code.startsWith('Arrow');
}
document.addEventListener("keydown", (e) => {
if (isMovementKey(e.code)) {
keys[e.code] = true;
}
});
document.addEventListener("keyup", (e) => {
if (isMovementKey(e.code)) {
keys[e.code] = false;
}
});
document.addEventListener("pointerlockchange", () => {
// In shadow DOM, document.pointerLockElement returns the host
// element (not the canvas inside the shadow root). Use the
// script's root — document in standalone, the shadow root in
// wrapped — to get the actual locked element.
isLocked = root.pointerLockElement === viewport;
// Suppress the entry overlay when the user is interacting with
// a form (we exited pointer lock on purpose, not as a "leave"
// gesture).
if (!interactiveTarget) {
overlay.classList.toggle("hidden", isLocked);
}
crosshair.classList.toggle("visible", isLocked);
});
overlay.addEventListener("click", () => {
viewport.requestPointerLock();
});
// Mouse look runs whenever pointer lock is active — including
// while the user is typing into a form field. The form field
// only receives keystrokes; the mouse still drives the camera
// so the player can look around the room while composing
// their message.
document.addEventListener("mousemove", (e) => {
if (!isLocked) return;
euler.setFromQuaternion(camera.quaternion);
euler.y -= e.movementX * lookSpeed;
euler.x -= e.movementY * lookSpeed;
euler.x = Math.max(
-Math.PI / 2 + 0.05,
Math.min(Math.PI / 2 - 0.05, euler.x),
);
camera.quaternion.setFromEuler(euler);
});
/* ==============================================================
Form interaction via raycasting
The poster on the left wall is rendered into the canvas via
drawElementImage, but the underlying <form> element is laid
out in a 1×1 staging container in the document. When the
user aims at the form and clicks, we raycast from the camera
through the screen center, find the UV intersection on the
posterMesh, convert that UV to screen coordinates over the
hidden staging form, and focus the underlying element via
elementFromPoint. From that point, normal browser behavior
takes over: the focused field receives keystrokes, the
submit button is clickable, etc.
While a field is focused, we exit pointer lock so the user
can move the cursor freely if they want, suppress WASD so
typing doesn't walk, and skip the entry overlay so the
scene stays visible.
============================================================== */
const raycaster = new THREE.Raycaster();
const interactionHint = $("interaction-hint");
function getStagingCanvas() {
return posterCanvas;
}
function focusFormElement(uv) {
const stagingCanvas = getStagingCanvas();
// Convert the UV hit on the 3D poster mesh into pixel
// coordinates inside the staging canvas's CSS layout box.
// Three.js UV.y is bottom-up; CSS coords are top-down.
const w = stagingCanvas.clientWidth || stagingCanvas.width;
const h = stagingCanvas.clientHeight || stagingCanvas.height;
const px = uv.x * w;
const py = (1 - uv.y) * h;
// Manual rect-based hit test instead of document.elementFromPoint.
// The staging canvas lives in an `overflow: hidden` 1×1
// container for layoutsubtree paint-record reasons, which
// clips the canvas visually and makes elementFromPoint
// return null for anything overflowing the container. We
// walk the form's interactive children directly and pick
// whichever one contains the local (px, py) point.
const canvasRect = stagingCanvas.getBoundingClientRect();
const candidates = stagingCanvas.querySelectorAll(
'input, select, textarea, button',
);
let target = null;
for (const el of candidates) {
const r = el.getBoundingClientRect();
const lx = r.left - canvasRect.left;
const ly = r.top - canvasRect.top;
if (
px >= lx &&
px < lx + r.width &&
py >= ly &&
py < ly + r.height
) {
target = el;
break;
}
}
if (!target) return false;
const tag = target.tagName;
if (tag === 'BUTTON') {
target.click();
return false;
}
if (tag === 'INPUT' && target.type === 'checkbox') {
target.checked = !target.checked;
target.dispatchEvent(new Event('change', { bubbles: true }));
return false;
}
if (tag === 'SELECT') {
// Native <select> dropdowns don't open under pointer lock
// (the browser suppresses the popup because the cursor is
// hidden), so each click on a select cycles forward
// through its options instead. The poster form has a
// small fixed set of topics, so a click-to-cycle UX is
// actually quite natural.
const sel = target;
const count = sel.options.length;
if (count > 0) {
sel.selectedIndex = (sel.selectedIndex + 1) % count;
sel.dispatchEvent(new Event('change', { bubbles: true }));
posterCanvas.requestPaint?.();
}
return false;
}
// preventScroll so the browser doesn't scroll the page to
// bring the off-screen staging canvas into view — the
// staging canvas lives in a 1×1 overflow:hidden container
// that extends off the stage.
target.focus({ preventScroll: true });
interactiveTarget = target;
target.addEventListener(
'blur',
() => {
if (interactiveTarget === target) interactiveTarget = null;
updateInteractionHint();
// If pointer lock was released (e.g. user hit Esc to
// exit typing), bring the Enter overlay back so they
// can re-enter. If lock is still active (normal click-
// to-blur path), just return to game mode.
if (!isLocked) {
overlay.classList.remove('hidden');
}
},
{ once: true },
);
return true;
}
function updateInteractionHint() {
if (!interactionHint) return;
if (interactiveTarget) {
interactionHint.textContent =
'Typing — arrows move, click to exit';
interactionHint.classList.add('visible');
} else {
interactionHint.classList.remove('visible');
}
}
// Crosshair-aim click. In pointer-locked mode the cursor is
// fixed at screen center, so the raycast ray is NDC (0, 0).
//
// We do NOT exit pointer lock when focusing a form field. The
// user stays in the room and keeps playing — arrow keys move,
// WASD goes into the input, the camera holds its current
// orientation. Clicking elsewhere on the viewport blurs the
// field without leaving the room.
viewport.addEventListener('click', () => {
if (!isLocked) return;
raycaster.setFromCamera({ x: 0, y: 0 }, camera);
const hits = raycaster.intersectObjects([posterMesh], false);
const hit = hits.length > 0 && hits[0].uv ? hits[0] : null;
// If a form field is currently focused:
// • clicking on another form field → focus that field
// instead (the raycast hit the poster again).
// • clicking anywhere else → blur and continue playing.
if (interactiveTarget) {
if (hit) {
const focused = focusFormElement(hit.uv);
if (focused) {
updateInteractionHint();
return;
}
}
interactiveTarget.blur();
interactiveTarget = null;
updateInteractionHint();
return;
}
if (!hit) return;
const focused = focusFormElement(hit.uv);
if (focused) updateInteractionHint();
});
// Esc while typing → the browser exits pointer lock as its
// default response. That fires `pointerlockchange` which
// already handles the cleanup (showing the Enter overlay,
// hiding the crosshair). We also blur the focused field here
// so the blur listener can run its own tidy-up.
document.addEventListener('keydown', (e) => {
if (interactiveTarget && e.key === 'Escape') {
interactiveTarget.blur();
}
});
/* ==============================================================
Poster form submit — hand off to endash.us contact form
with the user's intent pre-filled via query parameters.
============================================================== */
const posterNameEl = $('poster-name');
const posterEmailEl = $('poster-email');
const posterTopicEl = $('poster-topic');
const posterSubmitBtn = $('poster-submit');
if (posterSubmitBtn) {
posterSubmitBtn.addEventListener('click', (e) => {
e.preventDefault();
const name = (posterNameEl?.value || '').trim();
const email = (posterEmailEl?.value || '').trim();
const topic = (posterTopicEl?.value || 'Just saying hi').trim();
// Build the pre-filled endash.us contact URL. The contact
// modal on endash.us reads `showContact`, `contactTitle`,
// `contactSubtitle`, and `contactMessage` from the query
// string and opens itself with those values pre-populated.
const base = 'https://endash.us/';
const subtitle =
'From the HTML-in-Canvas 3D room demo — ' + topic;
const message = [
'Hi En Dash,',
'',
'I dropped this note from the 3D room in html-in-canvas.dev.',
'',
'Name: ' + (name || '(not provided)'),
'Email: ' + (email || '(not provided)'),
'Topic: ' + topic,
].join('\n');
const params = new URLSearchParams({
showContact: 'true',
contactTitle: 'A note from html-in-canvas.dev',
contactSubtitle: subtitle,
contactMessage: message,
});
// Open in a new tab so the user doesn't lose the 3D scene.
window.open(base + '?' + params.toString(), '_blank', 'noopener');
});
}
function updateMovement(dt) {
// Movement keeps running while the user is typing into a
// form field — `isMovementKey()` already filters which
// codes enter `keys` based on interactiveTarget, so this
// function only needs to gate on pointer lock.
if (!isLocked) return;
const forward = new THREE.Vector3(0, 0, -1);
forward.applyQuaternion(camera.quaternion);
forward.y = 0;
forward.normalize();
const right = new THREE.Vector3(1, 0, 0);
right.applyQuaternion(camera.quaternion);
right.y = 0;
right.normalize();
const velocity = new THREE.Vector3();
if (keys["KeyW"] || keys["ArrowUp"]) velocity.add(forward);
if (keys["KeyS"] || keys["ArrowDown"]) velocity.sub(forward);
if (keys["KeyD"] || keys["ArrowRight"]) velocity.add(right);
if (keys["KeyA"] || keys["ArrowLeft"]) velocity.sub(right);
if (velocity.lengthSq() > 0) {
velocity.normalize().multiplyScalar(moveSpeed * dt);
const newPos = camera.position.clone().add(velocity);
/* Keep inside room bounds */
const margin = 0.4;
newPos.x = Math.max(
-HALF_W + margin,
Math.min(HALF_W - margin, newPos.x),
);
newPos.z = Math.max(
-HALF_D + margin,
Math.min(HALF_D - margin, newPos.z),
);
/* Desk collision: don't walk into the desk */
if (newPos.z < -3.0 && Math.abs(newPos.x) < 1.7) {
newPos.z = Math.max(newPos.z, -3.0);
}
camera.position.copy(newPos);
}
}
/* ==============================================================
Content update intervals
============================================================== */
const LOG_MESSAGES = [
"Canvas texture sync complete",
"WebGL context acquired",
"All surfaces rendering at 60fps",
"drawElementImage() captured DOM",
"HTML element rendered to texture",
"layoutsubtree children updated",
"Texture uploaded to GPU: 0.3ms",
"Frame budget: 16.6ms \u2014 on target",
"Three.js scene rendered",
"Room navigation active",
"CanvasTexture.needsUpdate fired",
"requestPaint() scheduled",
];
let logIndex = 0;
function formatTime(date) {
const h = String(date.getHours()).padStart(2, "0");
const m = String(date.getMinutes()).padStart(2, "0");
const s = String(date.getSeconds()).padStart(2, "0");
return h + ":" + m + ":" + s;
}
function updateMonitorContent() {
const now = new Date();
/* Clock */
clockEl.textContent = formatTime(now);
/* Animated metrics */
const t = Date.now() / 1000;
const cpu = 50 + Math.sin(t / 2) * 25 + Math.random() * 8;
const mem = 35 + Math.sin(t / 3 + 1) * 20 + Math.random() * 5;
const net = 55 + Math.cos(t / 1.5) * 30 + Math.random() * 8;
const disk = 25 + Math.sin(t / 4 + 2) * 15 + Math.random() * 4;
cpuBar.style.width = cpu + "%";
memBar.style.width = mem + "%";
netBar.style.width = net + "%";
diskBar.style.width = disk + "%";
cpuVal.textContent = Math.round(cpu) + "%";
memVal.textContent = Math.round(mem) + "%";
netVal.textContent = Math.round(net) + "%";
diskVal.textContent = Math.round(disk) + "%";
/* Rolling log entries */
const time = formatTime(now);
for (let i = 0; i < logEntries.length - 1; i++) {
logEntries[i].innerHTML = logEntries[i + 1].innerHTML;
}
const msg = LOG_MESSAGES[logIndex % LOG_MESSAGES.length];
logEntries[logEntries.length - 1].innerHTML =
'<span class="log-time">[' +
time +
']</span> <span class="log-ok">OK</span> ' +
msg;
logIndex++;
monitorCanvas.requestPaint();
}
function updateTvContent() {
const now = new Date();
const h = String(now.getHours()).padStart(2, "0");
const m = String(now.getMinutes()).padStart(2, "0");
tvTime.textContent = h + ":" + m;
const days = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
tvDate.textContent =
days[now.getDay()] +
", " +
months[now.getMonth()] +
" " +
now.getDate();
}
/* Initial content population */
updateMonitorContent();
updateTvContent();
/* Update monitor every second */
setInterval(updateMonitorContent, 1000);
/* Update TV time every 10 seconds (animations update via requestPaint in render loop) */
setInterval(updateTvContent, 10000);
/* Poster repaints slowly when idle (just refresh the gradient
* orbs every 2s), but while the user is typing into a form
* field we bump to ~10 Hz so the caret and live text updates
* show up in the 3D scene promptly. */
setInterval(() => {
posterCanvas.requestPaint();
}, 2000);
setInterval(() => {
if (interactiveTarget) posterCanvas.requestPaint();
}, 100);
/* ==============================================================
Render loop
============================================================== */
let prevTime = performance.now();
let lastTvPaint = 0;
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
const dt = Math.min((now - prevTime) / 1000, 0.1);
prevTime = now;
/* Movement */
updateMovement(dt);
/* Request TV repaint frequently to capture CSS animations */
if (now - lastTvPaint > 33) {
tvCanvas.requestPaint();
lastTvPaint = now;
}
/* Pull the latest pixels from each layoutsubtree staging
* canvas into its mirror canvas, then mark the texture
* dirty. Three.js handles the GPU upload itself on the
* render call below. */
monitorStaging.sync();
posterStaging.sync();
tvStaging.sync();
renderer.render(scene, camera);
}
/* ==============================================================
Resize handler — observe the viewport canvas itself.
A ResizeObserver fires whenever the canvas's CSS box changes,
which works in both standalone (window resize) and shadow
contexts (stage resize).
============================================================== */
new ResizeObserver(() => {
const { w, h } = viewportSize();
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
}).observe(viewport);
/* ==============================================================
Start
============================================================== */
animate();
</script>
</body>
</html>