Devlog: Quite happy with the new features of composable crafting with derived stats - and notice that destroyed spells and 3D meshes use their actual particle effects and 3D geometry morphing into explosions! Sorry for the rough edges and bugs! #indiedev#indiegame#devlog #gamedev #mmorpg #threejs #lowpoly
Tried so much stuff regarding the UI. Opaque panels without transparent didnt work as they block the game view and item thumbnails doesnt display often if they have black designs similar to background color. Transparency without the "cutout" caused the panels to be unreadable when overlapping.
Here's what's happening:
Each open panel auto-clips itself with an SVG luminance mask whose hole is the union of overlap rects from every higher-z panel — recomputed in one coalesced requestAnimationFrame on show/hide/drag/resize/MutationObserver, so lower panels stay legible by literally not painting under the panel above. Pure vanilla JS + a single mask-image data URI per panel, GPU-composited so it's effectively free at panel sizes.
Added TTS, STT, auto translation, and a guard to all chat in game. Select from 100 voices to use for your character. Other players will read and listen to your chat messages in their native language. Happy with the latency - not bad, about 1 second or so. #indiedev#indiegame #mmorpg #threejs #gamdev
@sciasy_rpg Mine was discovering the physical box of Ultima Online in as store window in Denmark on vacation. Remember looking at the screenshots on the back and being amazed that the characters on there were actually real people.
Building Vaibie, a tool to prompt small games for the browser. Each game gets a SQLite database. 3D games are powered by Three.js. #vibecoding#threejs#gamedev#indiedev
Adding homing budgets to spells as a new strategic lever for picking what to shoot and how to aim (and having any chance at all to hit moving targets) #indiegame#gamedev#devlog#mmorpg#threejs
Just added in some FPS mechanics to the game. Projectile speed stats and homing buff potions will be in high demand if this makes it into production. #indiegame#indiedev#gamedev#threejs#mmorpg
I want to build a hybrid 3D UI in my Three.js project where some elements are real WebGL meshes (3D items, frames,
world-anchored markers) and others are HTML/CSS panels (text, scrollable lists, native form controls) — both sharing
the same camera so they appear to live in the same world. Help me set it up step by step.
Stack requirements
- Three.js (any recent version; r128+ is fine).
- The optional CSS3DRenderer.js example file from the Three.js examples
(three/examples/jsm/renderers/CSS3DRenderer.js). I need both CSS3DRenderer and CSS3DObject.
Architecture overview
1. One PerspectiveCamera shared between two renderers.
2. A standard THREE.Scene rendered by WebGLRenderer for the 3D content (game world, plus a small overlay scene for
HUD-like 3D meshes that should draw on top).
3. A separate THREE.Scene rendered by CSS3DRenderer for HTML panels positioned in 3D world space.
4. Both renderers are sized to the viewport, kept in sync on resize.
5. The CSS3D renderer's DOM element overlays the WebGL canvas with position: fixed; top: 0; left: 0; z-index above the
canvas, pointer-events: none on the overlay root, with specific interactive children opting back into pointer events.
Concrete setup tasks for you to do
1. Create the WebGLRenderer attached to a canvas, and a CSS3DRenderer whose domElement is appended to document.body
with the styling above. Give it an id like #css3d-overlay. Add CSS so #css3d-overlay, #css3d-overlay * {
pointer-events: none } and a couple of opt-in rules that re-enable pointer events for actual interactive widgets
(button, scrollable panes, etc).
updateProjectionMatrix on the camera.
3. In the render loop:
- webglRenderer.render(mainScene, camera).
- If you have an HUD/overlay 3D scene (small "always on top of world" meshes), set autoClear = false and render that
overlay scene with the same camera (consider depth-priming the camera-facing player rig first so the overlay loses to
player pixels but ignores world depth).
- Finally cssRenderer.render(cssScene, camera).
4. Build a small helper, mountCardInCSS3D(domElement, anchorPos, opts), that:
- Calls new THREE.CSS3DObject(domElement), sets initial scale (typical: 0.01 so 1 CSS px ≈ 1 cm of world), adds to
cssScene.
- Starts a per-frame loop that updates the CSS3DObject's position relative to anchorPos plus a camRight *
lateralOffset + worldUp * verticalLift so the panel sits next to its anchor.
- Supports three billboard modes:
- flat (default): cssObj.lookAt(camera.position) — panel faces the camera fully.
- yaw: lookAt a target at the same Y as the panel — stays upright in world even when camera pitches.
- screen: cssObj.quaternion.copy(camera.quaternion) — panel is parallel to the camera near-plane so it always
reads as a perfect rectangle regardless of where it sits in the viewport.
- Supports fixedScale (panel keeps its world size — shrinks on screen at distance), proportionalScale (scale clamped
between e.g. 0.35 and 7.0 times base, follows d / refDist), or the default scale-up-only behaviour that grows the
panel at distance to keep constant on-screen size.
- Supports anchorMode: 'top' | 'bottom' — top floats the panel above the anchor; bottom uses element.offsetHeight *
baseScale * scaleFactor / 2 so the panel's bottom edge sits at the anchor (useful when anchoring to a character's
feet).
5. Build a corresponding unmountCardFromCSS3D that cancels the loop, removes the CSS3DObject, and re-parents the
element back to body and hides it (so it can be re-mounted later without re-instantiating).
Bridging HTML drag-and-drop with WebGL meshes
Once the panel is anchored in world space, you'll want to interact across the layers. The trick is that the WebGL
meshes live on the canvas while the HTML panel lives in the CSS3D overlay — but they share the same screen coordinate
system because both use the same camera.
1. Pointer events: the CSS3D overlay's pointer-events: none lets mouse events pass through to the canvas. Only the
actual interactive widgets inside the panel (buttons, scrollable panes) opt back in.
2. Raycasting WebGL meshes from mouse position: convert clientX/Y to NDC using the canvas's getBoundingClientRect,
call raycaster.setFromCamera, then intersectObjects(myInteractiveList, false) against an explicit list of pickable
meshes (don't use the whole scene — keep an array of just the targets and update it as you add/remove markers).
3. Picking small / thin meshes: triangle raycasts miss thin geometry (thin tori, edges). For those cases use
screen-space proximity: project each marker's world center into screen pixels and pick the closest one within a
radius. Compute the radius from the marker's projected size so it scales with how zoomed in you are.
4. Drag from HTML → WebGL: on the inventory side, normal dragstart sets a global "I'm dragging this thing" state (item
id + type). On the canvas, listen for dragover / dragleave / drop. In dragover, project the cursor to find a
candidate WebGL slot via proximity; if it matches the item, call event.preventDefault() to mark it as a valid drop
target and tint the slot's material .emissive (e.g. soft white for "could drop here", green for "in range"). On drop,
run your equip / accept logic.
5. Drag from WebGL → HTML: on mousedown over a WebGL mesh, arm a drag state but don't engage until the cursor has
moved past a small threshold (so a plain click still fires). Once active, hide any HTML tooltips, swap the cursor to a
grabbing icon, and detach the dragged 3D mesh from its slot group → re-parent it into the overlay scene, then update
its world position each frame by unprojecting the cursor at a fixed camera-space depth. On mouseup use
document.elementFromPoint to find what's under the cursor — if it's an .inventory-slot HTML element, fire the equip
API; otherwise return the mesh to its slot.
6. Always preserve world transform when reparenting between scenes/groups: decompose the mesh's current matrixWorld
into pos/quat/scale, change the parent, then re-apply pos/quat/scale so the mesh doesn't jump.
Gotchas to watch for
- CSS3DRenderer doesn't depth-test against the WebGL canvas — CSS3D panels always draw on top. Design with that in
mind (panels float above the world, not behind it).
- For drop-position raycasts on hilly terrain, the iterative-plane fallback can oscillate. Use a ray-march along
raycaster.ray sampling your ground-height function at small steps (e.g. 1m) and lerp the crossing for sub-step
accuracy.
- Always clamp dropped positions to an interaction range from the player so far-hit rays don't fling items across the
map.
- When the player's mesh is a GLB with named bones, anchor 3D markers to bones via mesh.traverse(o => o.isBone &&
https://t.co/55mhvB6RYT === 'Head' && ...). When you want a stable layout that doesn't bounce with animations, anchor to the mesh root
instead and use camera-space offsets.
- For an arrayed slot layout (left/right columns around a character), compute offsets as camRight * x + worldUp * y so
the slots always sit screen-relatively beside the character but stay at world Y heights so they don't break in odd
ways during pitch.
- If you want WebGL items inside a slot to read as 3D even while pinned in screen space, keep the slot Group at world
identity rotation (don't billboard the whole group) — only billboard the flat plate / label, and let the 3D item keep
a fixed world orientation so orbiting the camera reveals different sides.
What to build first
Start with: (a) the two-renderer setup, (b) one CSS3D card with mountCardInCSS3D, (c) one WebGL marker mesh that lives
in an overlay scene, both anchored to the same dummy object so I can verify they stay aligned as I orbit the camera.
From there layer on drag-and-drop, proximity picking, slot groups, and tint feedback.
Treat camera Y rotation (yaw) and pitch as independent variables and test both. Verify on a wide variety of viewport
aspect ratios.
Using WebGL @webgl_webgpu and CSS3D with @threejs for bridging between the flat HTML UI and the 3D game world. #gamedev on @sciasy_rpg. Prompt in comments if you want to try it.