component
Screen
A display of any size (a phone, a monitor, a TV, a digital frame, a billboard) on a wall, a stand, a desk foot, or in a frame. Shows a seeded picture or desktop, a text banner, animated static, or a color, or a texture the app hands it.
⌖ Explore the library in 3D → · View as Markdown
Add it
npx @runek/cli add screen
Pulls sign, @react-three/fiber@^9.6.1, @react-three/rapier@^2.2.0, @runek/core@^0.15.0, three@^0.184.0.
Use it
import { Screen } from './runek/Screen'
<Screen position={[0, 0, 0]} seed={1} />
Or as a node in a world file (worlds as data):
{ "type": "Screen", "props": { "position": [0, 0, 0], "seed": 1 } }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
size |
[number, number] |
[1.6, 0.9] |
The lit area, [width, height] in units: a phone, a TV, a billboard. |
mount |
"stand" | "wall" | "desk" | "frame" |
"wall" |
Where it hangs or stands; wall by default. |
content |
"picture" | "windows" | "banner" | "static" | "color" |
A seeded picture (sky, sun, hills) or desktop windows, a banner of text, animated static, or a plain color. Seeded between picture and windows when unset. |
|
text |
string |
"Runek" |
The banner’s words. |
color |
color |
Background glow, and the tint of the picture and windows. Seeded when unset. | |
textColor |
color |
"#ffffff" |
Banner text color. |
on |
boolean |
true |
Lit; off, dark glass. |
frameColor |
color |
Bezel and stand; defaults to the palette’s metal (wood for a frame). |
|
texture |
Texture (code only) |
JSX only: a texture the app owns (a VideoTexture, a CanvasTexture, a render target’s texture) drawn in place of content. The screen loads nothing itself. |
|
position |
[x, y, z] |
[0, 0, 0] |
Position [x, y, z] in units (1 unit = 1 m, Y-up). |
rotation |
[x, y, z] |
[0, 0, 0] |
Euler rotation [x, y, z] in radians. |
seed |
number |
1 |
Seed for the deterministic variation: same seed, same result. |
Registry manifest
registry manifest /r/components/screen.json Self-contained JSON: inlined source plus resolved dependencies, exactly whatrunek add screen fetches.
Browse the whole catalog in the gallery →.
Source (Screen.tsx)
import { useFrame } from '@react-three/fiber'
import { CuboidCollider, RigidBody } from '@react-three/rapier'
import { pick, range, rng, sub, useWorld, type Vec3, type WorldComponentProps } from '@runek/core'
import { useMemo, useState } from 'react'
import { Color, ShaderMaterial, type Texture } from 'three'
import { Sign } from './Sign'
/** Flat on a `wall`, on two floor `stand` posts, on a `desk` foot, or in a wooden picture `frame`
* (a digital picture frame, on a wall). */
export type ScreenMount = 'wall' | 'stand' | 'desk' | 'frame'
/** What the screen shows when no `texture` is given. */
export type ScreenContent = 'picture' | 'windows' | 'banner' | 'static' | 'color'
export interface ScreenProps extends WorldComponentProps {
/** The lit area, [width, height] in units: a phone, a TV, a billboard. */
size?: [number, number]
/** Where it hangs or stands; `wall` by default. */
mount?: ScreenMount
/** A seeded `picture` (sky, sun, hills) or desktop `windows`, a `banner` of `text`, animated
* `static`, or a plain `color`. Seeded between picture and windows when unset. */
content?: ScreenContent
/** The banner's words. */
text?: string
/** Background glow, and the tint of the picture and windows. Seeded when unset. */
color?: string
/** Banner text color. */
textColor?: string
/** Lit; off, dark glass. */
on?: boolean
/** Bezel and stand; defaults to the palette's `metal` (`wood` for a frame). */
frameColor?: string
/** JSX only: a texture the app owns (a `VideoTexture`, a `CanvasTexture`, a render target's
* texture) drawn in place of `content`. The screen loads nothing itself. */
texture?: Texture
}
interface Shape {
pos: Vec3
size: [number, number]
color: string
round?: boolean
}
const TINTS = ['#6fa3d8', '#e3a46a', '#8fc6a8', '#b49be0', '#e07f7f', '#7fb8c9']
const OFF = '#101214'
const NOISE_VERTEX = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`
const NOISE_FRAGMENT = /* glsl */ `
uniform float uTime;
uniform vec2 uCells;
varying vec2 vUv;
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main() {
vec2 cell = floor(vUv * uCells);
float n = hash(cell + floor(uTime * 24.0) * vec2(1.7, 9.2));
float band = 0.85 + 0.15 * sin(vUv.y * 40.0 + uTime * 6.0);
gl_FragColor = vec4(vec3(n * band), 1.0);
}`
/** Flat shapes for a picture or a desktop, in screen-local meters, kept inside the screen. */
function compose(kind: 'picture' | 'windows', seed: number, tint: string, W: number, H: number) {
const r = rng(sub(seed, 1))
const base = new Color(tint)
const shade = (k: number, hue = 0) =>
base.clone().offsetHSL(hue, 0, 0).multiplyScalar(k).getStyle()
const out: Shape[] = []
if (kind === 'picture') {
const horizon = range(r, -0.15, 0.1) * H
out.push({ pos: [0, (H / 2 + horizon) / 2, 0], size: [W, H / 2 - horizon], color: shade(1.15) })
out.push({
pos: [0, (horizon - H / 2) / 2, 0],
size: [W, H / 2 + horizon],
color: shade(0.35, 0.25),
})
const m = Math.min(W, H)
const sunR = 0.12 * m
out.push({
pos: [
range(r, -0.3, 0.3) * W,
Math.min(H / 2 - sunR, horizon + range(r, 0.12, 0.28) * H),
0.0006,
],
size: [sunR, sunR],
color: '#fff3c4',
round: true,
})
for (let i = 0; i < 4; i++) {
const rad = Math.min(range(r, 0.2, 0.38) * m, H / 2)
const x = range(r, -0.5, 0.5) * W
out.push({
pos: [
Math.max(-W / 2 + rad, Math.min(W / 2 - rad, x)),
Math.max(-H / 2 + rad, horizon - rad * range(r, 0.8, 0.95)),
0.001 + i * 0.0002,
],
size: [rad, rad],
color: shade(i < 2 ? 0.55 : 0.4, 0.25),
round: true,
})
}
return out
}
out.push({ pos: [0, 0, 0], size: [W, H], color: tint })
const count = 1 + Math.floor(r() * 3)
for (let i = 0; i < count; i++) {
const w = range(r, 0.35, 0.7) * W
const h = range(r, 0.4, 0.75) * H
const x = range(r, -W / 2 + w / 2 + 0.02 * W, W / 2 - w / 2 - 0.02 * W)
const y = range(r, -H / 2 + h / 2 + 0.04 * H, H / 2 - h / 2 - 0.04 * H)
const z = 0.0006 * (i + 1)
out.push({ pos: [x, y, z], size: [w, h], color: shade(1.35) })
out.push({ pos: [x, y + h / 2 - 0.05 * h, z + 0.0002], size: [w, 0.1 * h], color: shade(0.75) })
const lines = 2 + Math.floor(r() * 4)
for (let l = 0; l < lines; l++) {
const lw = range(r, 0.3, 0.85) * w * 0.85
out.push({
pos: [x - w * 0.42 + lw / 2, y + h * 0.28 - l * h * 0.14, z + 0.0002],
size: [lw, 0.045 * h],
color: shade(0.6),
})
}
}
return out
}
/**
* A display of any size: a phone, a computer screen, a TV, a digital picture frame, or a large
* digital banner. It shows a seeded picture or desktop, a banner of text, animated static, or a
* plain color, all geometry, and an app can hand it a live `texture` (video, canvas, a render of
* another camera) to show instead; the screen itself never loads anything. On a `wall` or in a
* `frame` its origin is the middle of the screen with its back to the wall (local -Z); on a
* `stand` or a `desk` it is the foot. One cuboid collider.
*/
export function Screen({
position = [0, 0, 0],
rotation = [0, 0, 0],
seed = 1,
size = [1.6, 0.9],
mount = 'wall',
content,
text = 'Runek',
color,
textColor = '#ffffff',
on = true,
frameColor,
texture,
}: ScreenProps) {
const { unit, palette } = useWorld()
const W = size[0] * unit
const H = size[1] * unit
const framed = mount === 'frame'
const frame = frameColor ?? (framed ? palette.wood : palette.metal)
const m = Math.min(W, H)
const bezel = framed ? Math.max(0.03 * unit, m * 0.08) : Math.max(0.006 * unit, m * 0.015)
const thick = framed ? 0.03 * unit : Math.max(0.012 * unit, m * 0.03)
const look = useMemo(() => {
const r = rng(sub(seed, 0))
const kind = content ?? pick<ScreenContent>(r, ['picture', 'windows'])
const tint = color ?? pick(r, TINTS)
const shapes = kind === 'picture' || kind === 'windows' ? compose(kind, seed, tint, W, H) : []
return { kind, tint, shapes }
}, [seed, content, color, W, H])
const noise = useMemo(
() =>
new ShaderMaterial({
vertexShader: NOISE_VERTEX,
fragmentShader: NOISE_FRAGMENT,
uniforms: { uTime: { value: 0 }, uCells: { value: [160, 160 * (H / W)] } },
toneMapped: false,
}),
[W, H],
)
useFrame(({ clock }) => {
if (look.kind === 'static' && on && !texture) noise.uniforms.uTime.value = clock.elapsedTime
})
// the banner is one line, laid out at full height and then shrunk to fit the width
const [fit, setFit] = useState(1)
const standing = mount === 'stand' || mount === 'desk'
const lift = mount === 'stand' ? 0.8 * unit : 0.1 * unit + H * 0.1
const cy = standing ? lift + bezel + H / 2 : 0
const cz = standing ? 0 : thick / 2 + 0.005 * unit
const top = cy + H / 2 + bezel
const glass = 0.0005 * unit + thick / 2
return (
<RigidBody type="fixed" colliders={false} position={position} rotation={rotation}>
{standing ? (
<CuboidCollider
args={[W / 2 + bezel, top / 2, Math.max(thick, 0.08 * unit)]}
position={[0, top / 2, 0]}
/>
) : (
<CuboidCollider args={[W / 2 + bezel, H / 2 + bezel, thick / 2]} position={[0, 0, cz]} />
)}
<mesh position={[0, cy, cz]} castShadow>
<boxGeometry args={[W + bezel * 2, H + bezel * 2, thick]} />
<meshStandardMaterial
color={frame}
roughness={framed ? 0.6 : 0.4}
metalness={framed ? 0 : 0.3}
/>
</mesh>
{framed && (
<mesh position={[0, cy, cz + thick / 2 + 0.0003 * unit]}>
<planeGeometry args={[W + bezel * 0.6, H + bezel * 0.6]} />
<meshStandardMaterial color="#f2efe8" roughness={0.9} />
</mesh>
)}
<group position={[0, cy, cz + glass + (framed ? 0.0005 * unit : 0)]}>
{!on ? (
<mesh>
<planeGeometry args={[W, H]} />
<meshStandardMaterial color={OFF} roughness={0.12} metalness={0.4} />
</mesh>
) : texture ? (
<mesh>
<planeGeometry args={[W, H]} />
<meshBasicMaterial map={texture} toneMapped={false} />
</mesh>
) : look.kind === 'static' ? (
<mesh material={noise}>
<planeGeometry args={[W, H]} />
</mesh>
) : look.kind === 'color' || look.kind === 'banner' ? (
<>
<mesh>
<planeGeometry args={[W, H]} />
<meshBasicMaterial color={look.tint} toneMapped={false} />
</mesh>
{look.kind === 'banner' && text && (
<group position={[0, 0, 0.001 * unit]} scale={fit}>
<Sign
size={(H * 0.42) / unit}
color={textColor}
onBounds={(w, h) => w > 0 && setFit(Math.min(1, (W * 0.9) / w, (H * 0.8) / h))}
>
{text}
</Sign>
</group>
)}
</>
) : (
look.shapes.map((s) => (
<mesh
key={`${s.pos[0].toFixed(4)}:${s.pos[1].toFixed(4)}:${s.pos[2]}`}
position={[s.pos[0], s.pos[1], s.pos[2] * unit]}
>
{s.round ? (
<circleGeometry args={[s.size[0], 28]} />
) : (
<planeGeometry args={s.size} />
)}
<meshBasicMaterial color={s.color} toneMapped={false} />
</mesh>
))
)}
</group>
{mount === 'stand' &&
[-1, 1].map((s) => (
<group key={s} position={[s * W * 0.35, 0, 0]}>
<mesh position={[0, (cy - H / 2) / 2, -thick]} castShadow>
<boxGeometry args={[0.06 * unit, cy - H / 2, 0.06 * unit]} />
<meshStandardMaterial color={frame} roughness={0.5} metalness={0.3} />
</mesh>
<mesh position={[0, 0.015 * unit, -thick]} castShadow receiveShadow>
<boxGeometry args={[0.12 * unit, 0.03 * unit, 0.4 * unit]} />
<meshStandardMaterial color={frame} roughness={0.5} metalness={0.3} />
</mesh>
</group>
))}
{mount === 'desk' && (
<>
<mesh position={[0, (lift + H * 0.3) / 2, -thick]} castShadow>
<boxGeometry args={[0.05 * unit, lift + H * 0.3, 0.025 * unit]} />
<meshStandardMaterial color={frame} roughness={0.5} metalness={0.3} />
</mesh>
<mesh position={[0, 0.006 * unit, -thick]} castShadow receiveShadow>
<cylinderGeometry args={[0.11 * unit, 0.12 * unit, 0.012 * unit, 24]} />
<meshStandardMaterial color={frame} roughness={0.5} metalness={0.3} />
</mesh>
</>
)}
</RigidBody>
)
}