@rbxts/highlight-voxel
v0.1.0
Published
Highlight Roblox Terrain voxels in roblox-ts projects
Downloads
95
Maintainers
Readme
@rbxts/highlight-voxel
Highlight Roblox Terrain voxels from roblox-ts: show the player which 4×4×4 terrain cell they are looking at before they dig it, mark a selection, flag cells that are out of reach.
- Two looks.
"box"draws the grid cell as a box."surface"drapes a mesh over the part of the terrain surface that belongs to the voxel, with a line around it, so the highlight follows slopes, ridges and overhangs instead of floating above them or cutting into them. - Only highlighting. What can be targeted, input, tools, digging and UI stay in your game. You give it cell coordinates; it draws them.
- Client-side, no dependencies. Nothing is networked, no instances replicate.
| "surface": draped over the voxel's own terrain | "box": the grid cell |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
|
|
The same voxel with each renderer. The box is the 4×4×4 grid cell, so on a slope most of it is underground; the surface patch is the terrain the player would actually dig.
Install
npm install @rbxts/highlight-voxelQuick start
Highlight the voxel under the mouse:
import { RunService } from "@rbxts/services";
import { Highlighter, cellFromMouse } from "@rbxts/highlight-voxel";
RunService.RenderStepped.Connect(() => {
const cell = cellFromMouse();
Highlighter.setCells(cell ? [cell] : []);
});setCells is diffed: calling it every frame with an unchanged set costs nothing.
The same with the surface renderer. Handing the raycast result on is optional, but it saves the renderer about a fifth of its work and tells it which side of the voxel you mean (the underside of a ledge, not its top):
import { Highlighter, cellFromRaycast, raycastFromMouse } from "@rbxts/highlight-voxel";
Highlighter.setStyle({ renderer: "surface" });
RunService.RenderStepped.Connect(() => {
const result = raycastFromMouse();
Highlighter.setCell(result && cellFromRaycast(result), result);
});Cells
Terrain is a grid of 4×4×4-stud cells aligned to the world origin. A cell is a Vector3 of integer coordinates, the ones Terrain:WorldToCell returns. Every function that takes a cell rounds it first.
cellFromWorld(position, prefer?: "solid" | "empty"): Cell
cellFromRaycast(result, target?: "hit" | "adjacent"): Cell | undefined
cellFromMouse(options?): Cell | undefined
raycastFromMouse(options?): RaycastResult | undefined
sampleCell(cell, hit?): { material?, voxelMaterial? } // what the voxel is made of, see below
cellToWorld(cell): Vector3 // centre of the cell
cellBounds(cell): [min: Vector3, max: Vector3]
VOXEL_SIZE // 4cellFromRaycast returns undefined unless the ray hit terrain. "hit" (default) is the voxel being looked at, the one to dig; "adjacent" is the empty cell in front of it, the one to build in. cellFromMouse and raycastFromMouse ignore the local player's character unless you pass your own raycastParams; maxDistance defaults to 500 studs.
What a voxel is made of
A game usually has to know a voxel's material before it highlights it at all (a shovel must not highlight rock). The Material on your own aim raycast is the wrong thing to ask: a ray that lands near the boundary between two voxels resolves to one cell and reports the other's material. Measured on test terrain, 5% of surface hits did.
const result = raycastFromMouse();
const cell = result && cellFromRaycast(result);
const sample = cell && sampleCell(cell, result);
if (cell && sample?.voxelMaterial && tool.canWork(sample.voxelMaterial)) hover.setCell(cell, result);
else hover.setCell(undefined);| Field | |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| material | The material at the centre of the voxel's surface patch: what the player sees in the middle of the highlight. undefined when the voxel has no surface of its own (buried, or empty air). Water is ignored. |
| voxelMaterial | The material in the terrain's voxel data for the cell: what a terrain edit of that voxel acts on. undefined for an empty cell. Read fresh on every call, costs no raycasts. |
They can differ, and not because of grazing: Roblox blends the surface's material between neighbouring voxels, so a thinly filled voxel mostly looks like its neighbour (measured: a sand voxel at 24% occupancy whose surface was 81% grass). On 49 boundary cells where the aim ray reported the wrong material, voxelMaterial was right 49 times and material 42 times; the other 7 are such thin voxels. Use voxelMaterial for rules about what a tool may dig, material for what the player is looking at.
- Synchronous, works on cells that are not highlighted, with either renderer, and where
EditableMeshis unavailable. materialcomes out of the same computation and cache as the surface patch:sampleCell(cell, hit)followed bysetCell(cell, hit)casts once (measured: 136 rays for the sample, none for the highlight). "No surface" is cached too, so sampling a buried cell every frame is free after the first time.- Because it is cached, call
refresh()orrefresh(cell)after the terrain changed.
SurfaceHit, the type of every hit parameter, is RaycastResult | { position: Vector3; normal: Vector3 }: a plain object works wherever a raycast result does, e.g. in headless tests that cannot construct a RaycastResult. Anything unusable is ignored.
Highlighters
A VoxelHighlighter is a style plus a set of cells. Make one per look (hover, selection, invalid…); they are independent, and the same cell can be in several. Highlighter is a ready-made one with default options for games that need a single look.
const hover = new VoxelHighlighter({ color: new Color3(1, 1, 1) });
const selection = new VoxelHighlighter({ renderer: "surface", color: BLUE, fillTransparency: 0.6 });
selection.setCells(areaCells);
hover.setStyle({ color: inReach ? WHITE : RED }); // restyles live| Method | |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| enable(x, y, z) / enable(cell, hit?) | Add a cell. hit is an optional SurfaceHit, the raycast that found it (surface renderer only). |
| disable(x, y, z) / disable(cell) | Remove a cell. |
| toggle(cell) | Returns the new state. |
| isEnabled(cell) | |
| setCells(cells) | Replace the whole set; only the difference is redrawn. |
| setCell(cell \| undefined, hit?) | Make this the only cell, or clear. The hover call. |
| getCells() / clear() | |
| setStyle(partialStyle) / getStyle() | Merge into the style; existing highlights change at once. |
| setVisible(visible) / isVisible() | Hide without losing the set. |
| refresh(cell?) | Call it after the terrain changed: surface patches and sampleCell results are cached. With a cell, only that cell's cache entry is dropped and only it is redrawn; without, everything. Call it on the frame after the edit (see below). |
| getActiveRenderer() / onRendererChanged(callback) | What the handle is actually drawn with; differs from the style only when "surface" is unavailable. |
| destroy() | Afterwards every method is a silent no-op. |
Style
| Option | Default | |
| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| renderer | "box" | "box" or "surface" |
| color | green | Outline colour |
| transparency | 0.3 | Outline transparency; 1 hides the outline |
| lineThickness | 0.1 | Outline thickness in studs (the surface border is drawn at 30 pixels per stud) |
| fillColor | color | |
| fillTransparency | 0.9 | 1 = no fill |
| fillMaterial | Neon | Material of the fill, both renderers. Neon is not shaded by lighting, so the fill looks the same in a cave as in daylight |
| alwaysOnTop | true | Draw the outline (never the fill) through terrain |
| padding | 0.02 | Studs the box grows beyond the cell; box renderer only |
| merge | false | Draw the highlighter's cells as one shape (see below) |
Constructor options are the style plus parent (container for the visuals, default a VoxelHighlights folder in Workspace), visible and priority.
Merged highlights
With merge: true a highlighter's cells are drawn as one shape instead of cell by cell: no fill and no outline between two of its cells. Boxes become a single hull with an outline along its silhouette; surface patches keep their fill (neighbouring patches already meet) and lose the border lines between them, leaving one border around the group. Merging is per highlighter.
const selection = new VoxelHighlighter({ renderer: "surface", merge: true, color: BLUE, fillTransparency: 0.6 });
selection.setCells(areaCells);| 25 cells, merge: false | merge: true, surface | merge: true, box |
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
|
|
(The green cell in the middle is a second highlighter, the hover.)
The surface renderer
It raycasts the terrain around the voxel to find the surface the engine attributes to that voxel (the exact area where hovering selects it, including terrain that smoothing has pushed outside the cell's box) and moves the vertices of a pre-built EditableMesh onto it.
Requirements. EditableMesh works in Studio; a published experience needs Allow Mesh / Image APIs enabled in its settings. Where it is unavailable, and for cells with no surface of their own (buried, or empty air), the box is drawn instead. Nothing throws.
Cost. A patch is computed once, when a cell is first highlighted, not per frame; recently used cells come from a cache. Adding many cells at once is spread over several frames (about 2 ms per frame), so nothing stalls; the first cell of a frame, the hover case, is never delayed. At most 32 cells are drawn with the surface renderer at a time; more fall back to boxes.
VoxelHighlight.configure({ surfaceQuality: "low" }); // any time; visible highlights redraw| Quality | Raycasts per new cell (with / without a hit passed) | Time* | |
| ------------------ | --------------------------------------------------- | ---------- | --------------------------------------------------------- |
| "high" (default) | about 150 / 180 | 0.7–0.9 ms | Follows the voxel's outline closely |
| "low" | about 90 / 120 | 0.5–0.7 ms | Simpler outline, corners slightly clipped, still no holes |
* Measured in Studio on a desktop PC over 155 terrain cells. The rays are short and hit only terrain.
Terrain edits. Raycasts see a terrain edit only from the next frame (measured: ReplaceMaterial followed by a raycast in the same frame still reports the old material; one frame later the new one). So refresh() in the same frame as the edit recomputes against the old surface. Wait a frame (task.wait()), or refresh when your game learns that the edit has landed. voxelMaterial reads the voxel data and is current at once.
VoxelHighlight.getStats():
| Field | |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| highlighters, cells | Live highlighters, and enabled cells across the visible ones |
| visibleInstances | Parts, outlines and border lines the player can see right now, counted from the instances themselves: 0 proves nothing shows |
| surfaceCells, surfacePending | Cells drawn by the surface renderer, and how many of them still wait for their patch |
| surfaceQuality | The current preset |
| lastSurfaceRays, lastSurfaceMilliseconds | Cost of the most recently computed patch |
| surfaceComputed, surfaceCacheHits | Patches computed and patches served from the cache so far: together they show what a call really cost |
| degraded | Always 0 for now |

Passing the hit tells the renderer which side is meant: here the underside of a ledge, aimed at from below.
Limits. One patch per voxel: on a voxel with two separate surfaces (a thin ledge) the side you hit, or else the side facing the camera, is drawn. The patch is star-shaped around its centre, so a hard 90° corner inside one voxel is covered less exactly than smooth terrain.
Development
The package is developed inside a separate Rojo dev environment that syncs out/ into a Studio place; see CLAUDE.md and .claude/docs/.
npm install
npm run watch # compile src/ to out/ on change
npm run typecheck
npm run lint
npm run formatLicence
ISC © ShaderCloud. Use it for anything; keep the copyright notice.
