Raycasting and Queries
Perform spatial queries with raycasting, shape casting, and overlap tests. Learn about collision filtering, hit detection, and query optimization.
Raycasting and spatial queries allow you to detect objects along a line or within a volume. Web Engine supports ray casting (infinite line), shape casting (swept volume), and overlap queries(volume intersection). These are essential for line-of-sight checks, ground detection, hit detection, and AI perception.
Query Types#
Ray Cast
Cast an infinite ray and get the first hit. Used for line-of-sight, shooting, and ground detection.
Shape Cast
Sweep a shape along a direction. Used for character movement prediction and thick raycasts.
Overlap Query
Test if shapes intersect. Used for area detection, explosion radius, and proximity checks.
Collision Filtering
Filter queries by collision groups. Only test specific layers or object types.
Ray Casting#
Ray casting shoots an infinite ray from an origin in a direction and returns the first hit. It's the most common spatial query for hit detection and line-of-sight.
Basic Ray Cast#
// Cast a ray from entity position forwardexport default (api) => { return async (dt) => { const origin = api.position; const direction = { x: 0, y: 0, z: 1 }; // Forward const maxDistance = 10.0; const hit = await api.raycast(origin, direction, maxDistance); if (hit) { api.log(`Hit entity ${hit.eid}`); api.log(`Distance: ${hit.distance}`); api.log(`Point: ${hit.point.x}, ${hit.point.y}, ${hit.point.z}`); api.log(`Normal: ${hit.normal.x}, ${hit.normal.y}, ${hit.normal.z}`); } else { api.log("No hit"); } };};Hit Result#
Raycast returns a RaycastHit object with information about the collision:
| Property | Type | Description |
|---|---|---|
| eid | number | Entity ID of the hit object |
| distance | number | Distance from ray origin to hit point |
| point | vec3 | World position of hit point |
| normal | vec3 | Surface normal at hit point (perpendicular to surface) |
interface RaycastHit { eid: number; distance: number; point: [number, number, number]; normal: [number, number, number];}Common Raycast Patterns#
Ground Detection#
// Detect ground below characterexport default (api) => { return async (dt) => { const origin = api.position; const down = { x: 0, y: -1, z: 0 }; const maxDist = 2.0; const hit = await api.raycast(origin, down, maxDist); if (hit && hit.distance < 1.1) { api.log("On ground"); api.isGrounded = true; } else { api.log("In air"); api.isGrounded = false; } };};Line of Sight#
// Check if enemy has line of sight to playerexport default (api) => { const playerEntity = api.getEntityByName("Player"); return async (dt) => { const origin = api.position; // Direction to player const toPlayer = { x: playerEntity.position.x - origin.x, y: playerEntity.position.y - origin.y, z: playerEntity.position.z - origin.z, }; // Normalize direction const length = Math.sqrt( toPlayer.x * toPlayer.x + toPlayer.y * toPlayer.y + toPlayer.z * toPlayer.z ); const direction = { x: toPlayer.x / length, y: toPlayer.y / length, z: toPlayer.z / length, }; const hit = await api.raycast(origin, direction, length); if (hit && hit.eid === playerEntity.id) { api.log("Player visible!"); // Start attacking } else { api.log("Player blocked by obstacle"); // Obstacle in the way } };};Shooting / Hit Detection#
// Raycast weapon shootingexport default (api) => { const damage = 25; const range = 100.0; return async (dt) => { if (api.input.fire) { // Get camera forward direction const forward = api.camera.forward; const origin = api.camera.position; const hit = await api.raycast(origin, forward, range); if (hit) { api.log(`Hit ${hit.eid} at distance ${hit.distance}`); // Apply damage to hit entity api.sendMessage(hit.eid, { type: "damage", amount: damage, point: hit.point, normal: hit.normal, }); // Spawn hit effect at impact point api.spawnPrefab("BulletImpact", hit.point); } } };};Reflecting Rays (Bouncing)#
// Reflect ray off surface normalexport default (api) => { return async (dt) => { let origin = api.position; let direction = { x: 1, y: -1, z: 0 }; // Diagonal const bounces = 3; for (let i = 0; i < bounces; i++) { const hit = await api.raycast(origin, direction, 10.0); if (hit) { // Draw debug line api.drawLine(origin, hit.point, "red"); // Reflect direction around normal const dot = 2 * ( direction.x * hit.normal.x + direction.y * hit.normal.y + direction.z * hit.normal.z ); direction = { x: direction.x - dot * hit.normal.x, y: direction.y - dot * hit.normal.y, z: direction.z - dot * hit.normal.z, }; // Continue from hit point origin = hit.point; } else { break; } } };};Shape Casting#
Shape casting sweeps a 3D shape along a direction, returning the first hit. It's like a "thick raycast" and is useful for character movement prediction and collision avoidance.
// Shape cast with a sphereexport default (api) => { return async (dt) => { const shapeType = 1; // Sphere const size = [0.5, 0, 0]; // Radius 0.5m const origin = api.position; const direction = { x: 0, y: 0, z: 1 }; const maxDistance = 5.0; const hit = await api.shapecast( shapeType, size, origin, direction, maxDistance ); if (hit) { api.log(`Shape hit at distance ${hit.distance}`); } };};| Shape Type | Size Parameters | Use Case |
|---|---|---|
| 0 (Box) | [halfX, halfY, halfZ] | Character sweep, rectangular volumes |
| 1 (Sphere) | [radius, 0, 0] | Projectile prediction, circular sweep |
| 2 (Capsule) | [radius, halfHeight, 0] | Character sweep, cylindrical volumes |
Character Movement Prediction#
// Predict if movement will collideexport default (api) => { const characterRadius = 0.5; const characterHeight = 2.0; return async (dt) => { const { move } = api.input; // Calculate desired movement const moveDir = { x: move.x * 5.0 * dt, y: 0, z: move.y * 5.0 * dt, }; // Shape cast capsule in movement direction const hit = await api.shapecast( 2, // Capsule [characterRadius, characterHeight / 2, 0], api.position, moveDir, 1.0 ); if (hit) { api.log("Movement blocked - slide along wall"); // Implement sliding logic } else { // Safe to move api.position = { x: api.position.x + moveDir.x, y: api.position.y + moveDir.y, z: api.position.z + moveDir.z, }; } };};Collision Filtering#
Filter raycasts and queries by collision groups to test only specific object types. This improves performance and prevents unwanted hits.
import { CollisionGroups } from '@web-engine/core'; // Raycast that only hits enemiesconst hit = await api.raycast( origin, direction, maxDistance, { filterGroups: CollisionGroups.ENEMY }); // Raycast that hits enemies and environmentconst hit = await api.raycast( origin, direction, maxDistance, { filterGroups: CollisionGroups.ENEMY | CollisionGroups.ENVIRONMENT }); // Raycast that ignores player (for AI line of sight)const hit = await api.raycast( origin, direction, maxDistance, { filterGroups: CollisionGroups.ALL & ~CollisionGroups.PLAYER });Performance Optimization
Always use collision filtering when you know what you're looking for. Testing only the ENEMY group is much faster than testing all objects and filtering in script.
Overlap Queries#
Overlap queries test if a volume intersects with colliders. Useful for explosion radius, area-of-effect detection, and proximity checks.
// Detect entities in explosion radiusexport default (api) => { const explosionRadius = 5.0; const explosionDamage = 100; return (dt) => { if (api.input.detonate) { // Get all entities overlapping sphere const overlapping = api.overlapSphere( api.position, explosionRadius ); for (const eid of overlapping) { // Calculate damage based on distance const entity = api.getEntity(eid); const dx = entity.position.x - api.position.x; const dy = entity.position.y - api.position.y; const dz = entity.position.z - api.position.z; const distance = Math.sqrt(dx*dx + dy*dy + dz*dz); const damage = explosionDamage * (1 - distance / explosionRadius); // Apply damage api.sendMessage(eid, { type: "damage", amount: damage, }); } // Spawn explosion effect api.spawnPrefab("Explosion", api.position); } };};Performance Optimization#
Limit Ray Distance#
Always specify a maximum distance. Infinite rays are expensive.
// Bad: Infinite ray (expensive)const hit = await api.raycast(origin, direction); // Good: Limited distanceconst hit = await api.raycast(origin, direction, 10.0);Use Collision Filtering#
// Bad: Test all objects, filter in scriptconst hit = await api.raycast(origin, direction, 100.0);if (hit && hit.collisionGroup === CollisionGroups.ENEMY) { // Handle hit} // Good: Filter at physics levelconst hit = await api.raycast( origin, direction, 100.0, { filterGroups: CollisionGroups.ENEMY });Batch Raycasts#
If you need multiple raycasts, batch them together to reduce overhead.
// Batch multiple raycasts (more efficient)const results = await api.batchRaycast([ { origin: pos1, direction: dir1, maxDistance: 10 }, { origin: pos2, direction: dir2, maxDistance: 10 }, { origin: pos3, direction: dir3, maxDistance: 10 },]);Reduce Query Frequency#
// Don't raycast every frame if not neededexport default (api) => { let timer = 0; const checkInterval = 0.2; // Check every 200ms return async (dt) => { timer += dt; if (timer >= checkInterval) { timer = 0; // Perform expensive raycast const hit = await api.raycast(origin, direction, 50.0); // ... } };};Best Practices#
- Always specify maxDistance to avoid infinite rays
- Use collision filtering to test only relevant objects
- Cache raycast results - don't cast the same ray every frame
- Use shape casts instead of raycasts for thick objects
- Batch multiple raycasts when possible
- Consider raycast frequency - not everything needs frame-perfect accuracy
- Use
raycastSyncfor immediate results in hot paths - Visualize rays with debug drawing during development
Async vs Sync
raycast() is async (returns Promise) and may take a frame.raycastSync() returns immediately but can block the frame. Use async for most cases, sync only when you need instant results.
Debug Visualization#
// Visualize raycasts with debug linesexport default (api) => { return async (dt) => { const origin = api.position; const direction = { x: 0, y: 0, z: 1 }; const maxDistance = 10.0; const hit = await api.raycast(origin, direction, maxDistance); if (hit) { // Draw red line to hit point api.drawLine(origin, hit.point, "red"); // Draw green sphere at hit point api.drawSphere(hit.point, 0.1, "green"); // Draw normal arrow const normalEnd = { x: hit.point.x + hit.normal.x, y: hit.point.y + hit.normal.y, z: hit.point.z + hit.normal.z, }; api.drawLine(hit.point, normalEnd, "blue"); } else { // Draw gray line to max distance const endPoint = { x: origin.x + direction.x * maxDistance, y: origin.y + direction.y * maxDistance, z: origin.z + direction.z * maxDistance, }; api.drawLine(origin, endPoint, "gray"); } };};Common Pitfalls#
| Problem | Cause | Solution |
|---|---|---|
| Ray not hitting | Direction not normalized | Normalize direction vector |
| Hitting self | Ray starts inside collider | Offset origin slightly |
| Performance issues | Too many raycasts per frame | Batch or reduce frequency |
| Wrong hits | No collision filtering | Use filterGroups parameter |
| Unreliable ground detection | Ray too short | Increase maxDistance |