A CRT terminal in the world
Build a working computer terminal on a curved CRT screen in a level: a login form you can type into, radio channels, a pop-up window you can drag, resize and close with an animation, and a bin you can drop files into. Then put it on a mesh with scan lines and a phosphor glow.
The video was made prior to the creation of Folio Studio
One document and one component. No Blueprint graph, no widget tree; the only C++ points an input action at the component.
The finished thing is examples/crt_terminal.rml. Open it beside this page.
./bin/win64/uipreview.exe examples/crt_terminal.rml --root . --size 1200x9001. The shape of the problem
The screen is an oval, wider than tall. The document is a rectangle, because a render target and a projection both are.
The corners of the document are not on the mesh, so nothing may be laid out there.
The oval is a radial falloff rather than border-radius: 50%: a hard edge would show on the mesh, and the falloff reads as glass there and as an oval on a flat surface.
body {
font-family: Roboto Mono;
font-size: 32dp;
color: #7dff9b;
background-color: transparent;
width: 100%;
height: 100%;
box-sizing: border-box;
position: relative;
overflow: hidden;
}width: 100% and height: 100% both matter. A document is laid out shrink-to-fit against its context regardless of display, so without them the body is content-height and the background stops partway down.
Content sits inside the ellipse's inscribed rectangle, which is 1/sqrt(2) of each axis, about 70%:
.screen {
position: absolute;
left: 15%; top: 15%;
width: 70%; height: 70%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}Then each row gets its own width, so the lines nearest the top and bottom stay clear of the curve:
.narrow { width: 62%; }
.mid { width: 84%; }
.wide { width: 100%; }The result is a lens-shaped block of text, not a centred rectangle.
2. Text you can type into
<div class="wide row">
<span class="label">OPERATOR</span>
<input type="text" class="field" id="operator" maxlength="24"/>
</div>
<div class="wide row">
<span class="label">ACCESS</span>
<input type="password" class="field" id="access" maxlength="16"/>
</div>input is one of the few real element types. Caret, selection, editing and clipboard come from RmlUi, and a field on a monitor in a room behaves like a field on a menu.
Style the caret with caret-color, and the focus state with :focus-visible, never :focus, so the ring stays hidden during mouse use.
While a field has focus, letters are letters. Folio's WASD and IJKL navigation schemes stand down automatically, so typing works. See input and navigation.
3. Channels and buttons
<div class="channel">
<input type="radio" name="channel" id="ch_a" checked/>
<span>ALPHA</span>
</div>Radios share a name and RmlUi handles the exclusivity. The label is the sibling <span>, which also makes the control count as named for the accessibility check; see accessibility.
<button id="engage">ENGAGE</button><button> is not a button. It is a generic element like div, with no focus behaviour and no click handling until it gets tab-index: auto:
button { tab-index: auto; nav: auto; }With that, Enter, Space and the gamepad confirm button all synthesise a click.
4. A window you can move and resize
No code:
<div id="win" class="window">
<handle class="titlebar" move_target="win">DISK UTILITY</handle>
<div class="close" folio-close="win" folio-label="Close">X</div>
<div class="window-body">…</div>
<handle class="grip" size_target="win"></handle>
</div>/* Absolute, because a handle writes left, top, width and height, and those mean nothing to a
flex item. */
.window { position: absolute; left: 12%; top: 22%; width: 420dp; height: 260dp; }
.grip { position: absolute; right: 0; bottom: 0; width: 20dp; height: 20dp; }<handle> is a real element type. move_target moves the element with that id and size_target resizes it.
5. Icons you can drag, and a bin
<div id="icon_a" class="desk-icon" drag="drag-drop" folio-drag="move" style="left: 28dp; top: 66dp;">
<span class="glyph"></span>CORE.DAT
</div>
<div class="bin" folio-drop="remove">
<span class="glyph"></span>RECYCLE
</div>drag: drag-drop, not drag: drag. RmlUi only looks for a drop target when the dragged element's drag is drag-drop or clone. With plain drag the bin never hears about it and folio-drop never runs.
folio-drag="move" is what moves it. drag alone fires events and leaves the element where it was. The element must be position: absolute, because folio-drag writes left and top.
Style the states, or a player cannot tell whether a drop lands:
.desk-icon.folio-dragging { opacity: 0.55; }
.bin.folio-dragover { border-color: #7dff9b; background-color: #7dff9b1a; }folio-drop and folio-close are looked for up the tree, so dropping on the bin's glyph or its label is dropping on the bin.
6. Animating things out
Both the close and the drop set folio-closing and wait for an animation before removing anything:
@keyframes consume { from { transform: scale(1); opacity: 1; }
to { transform: scale(0.05); opacity: 0; } }
.desk-icon.folio-closing { animation: 0.35s cubic-in consume; }
@keyframes suck { to { transform: translateY(520dp) scale(0.04); opacity: 0; } }
.window.folio-closing { animation: 0.5s cubic-in suck; }Without an animation on the class, the element goes at once.
On a surface, the render target is the document, so anything translated past its edge is clipped, and text that leaves the context is culled outright. An exit animation that slides off screen vanishes partway. Shrink towards an edge instead, as suck does.
Two animation rules that never warn:
- Times are seconds.
msdoes not exist.0.35s, never350ms: the latter parses as a reverse adjustment factor and produces no animation at all. - The easing names are RmlUi's.
cubic-in,cubic-out,cubic-in-out.ease-outdoes not exist; a misspelled name is read as a property name and silently never fires.
7. The CRT look
Three stacked overlays, all pointer-events: none so they never eat a click:
.scanlines { decorator: shader("scanlines #7fe8ff90 0.004 0.5"); }
.vignette { decorator: radial-gradient(circle farthest-corner at 50% 50%, #00000000 60%, #000000b0); }
.roll { /* a slow vertical sweep, animated with transform: translateY */ }decorator: shader(...) reaches the three effects RCSS has no syntax for: additive, scanlines and sweep. They composite premultiplied, so a colour with zero alpha adds to what is under it, identically in both renderers.
The glow on the title is a font effect; text-shadow does not exist:
.title { font-effect: glow(3dp #2bff7a80); }A font effect reaches about 0.104em, so 3dp of glow on 32dp text is well inside it and 3dp on 13dp text would be clamped with a warning. See text and fonts.
8. Onto a mesh
Add a Folio World Document component to an actor with the CRT mesh.
Document Path | examples/crt_terminal.rml |
Draw Mode | Surface |
World Size | the screen's size in world units: for a 135.97 × 107.314 monitor, that |
Pixels Per Unit | pick so WorldSize × PixelsPerUnit lands near your target resolution |
Surface is the only mode with layer effects: the vignette gradient, any filter and any box-shadow need an offscreen target. The price is one resample, which is why it is not the default.
Then bind the target to a material. /Folio/M_FolioSurface ships with the plugin: instance it, and feed its Element texture parameter from the component's OnSurfaceChanged event.
Full setup, including the assert you get from an oversized Pixels Per Unit: documents in the world. The material domain is in world document settings.
Scan lines in the material
The document's scan lines are a decorator, drawn flat. For scan lines that bend with the glass and roll independently of the UI, do it in the material, as the shipped M_FolioSurfaceCRT node export does.
The graph, in order:
- TextureCoordinate:
CoordinateIndex 1,VTiling 3. UV1 so it is independent of the element's own UVs, tiled three times down the screen. - Panner: that coordinate,
Timefrom a Time node,Speedfrom a MakeVector2 whose Y is a ScalarParameterScanLineSpeed. Panning only in V, at a speed the material instance sets. - TextureSampleParameter2D
ScanLines: the panned coordinate, sampling acrt_linestexture. - Multiply: its red channel by a ScalarParameter
ScanLineAlpha, so the effect has a strength dial. - Lerp:
A = 1,B = 0,Alpha =that multiply. This turns the scan-line mask into an exponent that is 1 where there is no line and 0 where there is. - Power:
Base =the Element sample's RGB,Exp =that lerp.
Driving an exponent rather than multiplying makes it look like a CRT, not a striped overlay: a power curve keeps bright phosphor bright and eats the dim areas, as the scan gap does.
The node export is at dev/M_FolioSurfaceCRT.nodes.txt: copy its contents and paste into a material graph.
9. Pointing input at it
The component takes pointer and keyboard input; you tell it where the pointer is.
// Drive the pointer from where the player is looking. The trace is the game's, so it can use
// whatever channel and reach the rest of the interaction system already uses.
FHitResult Hit;
if (GetWorld()->LineTraceSingleByChannel(Hit, Start, End, ECC_Visibility))
{
Terminal->MovePointerToHit(Hit);
}
// Then the usual press and release from your input action.
Terminal->PressPointer(EKeys::LeftMouseButton);
Terminal->ReleasePointer(EKeys::LeftMouseButton);
// And hand it the keyboard so the fields can be typed into.
Terminal->FocusDocument();The button is named rather than assumed, so a right click can mean something different from a left one, and ClearPointer() takes the pointer off the surface.
Pointer From Player View | trace from the camera rather than from a supplied position |
Pointer Uses Mesh UV | read the hit's UV rather than projecting onto the component's plane. On for a curved screen |
Invert Pointer V | UV conventions differ by exporter. If the pointer is mirrored vertically, this is the fix, not a curvature problem |
Show Cursor | draw a cursor on the surface, since the OS one is elsewhere |
Release Focus gives the keyboard back to the game.
If the pointer is consistently offset, check Invert Pointer V before the geometry.
folio.World.ShowSurface 1 logs what the pointer is doing, to tell "nothing is driving the pointer" from "the pointer is in the wrong place".
10. Check it
./bin/win64/uipreview.exe examples/crt_terminal.rml --root . --strict --a11yBoth clean on the shipped document. Then in the editor:
folio.Reload | rebuild from disk without leaving PIE |
folio.World.Verify | confirm the world path is drawing what it should |
folio.Surface.Shot | write the surface render to a PNG, comparable with the preview's |
What you have learned
- layout under a non-rectangular constraint (§1)
- real form controls and focus (§2, §3)
<handle>move and resize with no code (§4)drag-drop,folio-drag,folio-dropand the state classes (§5)- animated removal that the engine waits for (§6)
- shader decorators and font effects (§7)
- a document on a mesh, and a material that treats it as a screen (§8)
- pointer and keyboard into a world document (§9)
Where to go next
- An inventory screen: the same drag vocabulary against a data model.
- Documents in the world: the other two draw modes and when to use them.
- Nameplates and markers: the screen-space counterpart.