Chapter 5.6: Seeing Everything - TracePlus
Raycasting—shooting an invisible line to detect what it hits—is fundamental to interactive game logic. Need to know what the player is looking at? Raycast. Want a laser turret to detect targets? Raycast. Vanilla VScript's TraceLine function exists, but it has a fatal flaw: it only sees world geometry. It's completely blind to entities. PCapture-Lib's TracePlus fixes this, and adds portal support.
Raycasting is often used for weapon implementation to find out where the player has hit
The Problem: TraceLine is Broken
Let's say you want to create a security laser that detects when the player blocks it.
// Vanilla VScript attempt
function CheckLaserBeam() {
local startPos = laserEmitter.GetOrigin()
local endPos = laserReceiver.GetOrigin()
local fraction = TraceLine(startPos, endPos, null)
if (fraction < 1.0) {
printl("Laser blocked!")
}
return 0.1 // Think loop
}
This code will only detect walls. If the player, a cube, or any other entity blocks the laser, TraceLine returns 1.0 (no hit). The laser is useless for gameplay.
Critical Limitation of TraceLine: The standard TraceLine(startPos, endPos, ignoreEntity) can only detect world geometry (walls, floors, ceilings). It completely ignores all entities: players, props, NPCs, cubes, turrets—everything. This makes it almost useless for interactive game mechanics.
The Solution: TracePlus.Bbox
TracePlus provides multiple trace functions that actually work with entities.
Basic Entity Detection
// With PCapture-Lib: Actually detects entities
function CheckLaserBeam() {
local startPos = laserEmitter.GetOrigin()
local endPos = laserReceiver.GetOrigin()
local trace = TracePlus.Bbox(startPos, endPos)
if (trace.DidHit()) {
local hitEntity = trace.GetEntity()
if (hitEntity != null) {
printl("Laser blocked by: " + hitEntity.GetClassname())
if (hitEntity.IsPlayer()) {
TriggerAlarm()
}
} else {
printl("Laser blocked by world geometry")
}
}
return 0.1
}
Understanding the Result
All TracePlus functions return a result object with useful methods:
local trace = TracePlus.Bbox(startPos, endPos)
// Did we hit anything?
if (trace.DidHit()) {
// Get the exact hit position
local hitPos = trace.GetHitPos()
// Get what we hit
local hitEntity = trace.GetEntity() // null if world geometry
// Check if we hit world vs entity
if (trace.DidHitWorld()) {
printl("Hit a wall")
} else {
printl("Hit entity: " + hitEntity.GetName())
}
// Get the surface normal (direction the surface faces)
local normal = trace.GetImpactNormal()
// Get how far along the ray we hit (0.0 to 1.0)
local fraction = trace.GetFraction()
}
Trace Types: Cheap vs Bbox
TracePlus offers two trace algorithms, each with different trade-offs:
TracePlus.Cheap: Fast, World-Only
This is a thin wrapper around vanilla TraceLine. It's fast but only detects world geometry. Use it when you're certain you only need to check for walls/floors.
// Fast trace for world geometry only
local trace = TracePlus.Cheap(startPos, endPos)
if (trace.DidHit()) {
printl("Hit world at: " + trace.GetHitPos())
// Can still get surface normal
local normal = trace.GetImpactNormal()
}
TracePlus.Bbox: Full Detection
This is the real star. It detects both world geometry and entities by testing ray intersection with entity bounding boxes. Slightly slower, but actually useful.
// Full trace with entity detection
local trace = TracePlus.Bbox(startPos, endPos)
if (trace.DidHit()) {
if (trace.GetEntity()) {
printl("Hit entity: " + trace.GetEntity().GetClassname())
} else {
printl("Hit world geometry")
}
}
Ignoring Entities
Often you want to ignore certain entities (like the player when tracing from their eyes).
local player = GetPlayerEx()
local eyePos = player.EyePosition()
local lookDir = player.EyeForwardVector()
local endPos = eyePos + (lookDir * 1000)
// Ignore the player themselves
local trace = TracePlus.Bbox(eyePos, endPos, player)
if (trace.DidHit() && trace.GetEntity()) {
printl("Player is looking at: " + trace.GetEntity().GetName())
}
// Ignore multiple entities (pass an array)
local ignoreList = [player, playerWeapon, playerAttachment]
local trace2 = TracePlus.Bbox(eyePos, endPos, ignoreList)
FromEyes: Convenient Player Traces
Tracing from the player's view is so common that PCapture provides shortcuts.
local player = GetPlayerEx()
// Trace 500 units from where player is looking
local trace = TracePlus.FromEyes.Bbox(500, player)
if (trace.DidHit()) {
printl("Player is looking at: " + trace.GetHitPos())
}
// Cheap version (world-only)
local cheapTrace = TracePlus.FromEyes.Cheap(1000, player)
Advanced Filtering: TracePlus.Settings
For complex scenarios, create custom trace settings to filter what gets detected.
Ignore by Classname
// Ignore all triggers and brush entities
local settings = TracePlus.Settings.new()
.SetIgnoredClasses(["trigger_", "func_brush"])
local trace = TracePlus.Bbox(startPos, endPos, null, settings)
// This trace will pass through all triggers and func_brushes
Ignore by Model
// Ignore all glass props
local settings = TracePlus.Settings.new()
.SetIgnoredModels(["models/props/glass"])
local trace = TracePlus.Bbox(startPos, endPos, null, settings)
Custom Filter Functions
For ultimate control, provide custom filter functions.
// Only hit entities with health > 50
local settings = TracePlus.Settings.new()
.SetCollisionFilter(function(entity, note) {
return entity.GetHealth() > 50
})
// Ignore entities that are invisible
local settings2 = TracePlus.Settings.new()
.SetIgnoreFilter(function(entity, note) {
return entity.GetAlpha() == 0
})
local trace = TracePlus.Bbox(startPos, endPos, null, settings)
Precision Settings
// For hitting very thin objects or getting precise normals
local preciseSettings = TracePlus.Settings.new()
.SetDepthAccuracy(2) // Lower = more precise (default 5, min 0.3)
.SetBynaryRefinement(true) // Extra precision for hit point
local trace = TracePlus.Bbox(startPos, endPos, null, preciseSettings)
The Legendary Feature: Portal Tracing
This is where PCapture-Lib becomes truly magical. Portal tracing lets rays pass through portals, correctly transforming position and direction.
// Trace that goes through portals
local trace = TracePlus.PortalBbox(startPos, endPos)
if (trace.DidHit()) {
printl("Final hit position: " + trace.GetHitPos())
// Check if we went through any portals
local portalInfo = trace.GetAggregatedPortalEntryInfo()
if (portalInfo.len() > 0) {
printl("Trace passed through " + portalInfo.len() + " portal(s)")
foreach (entry in portalInfo) {
printl("Portal entry at: " + entry.GetHitPos())
}
}
}
Portal Trace Types
// Cheap portal trace (world-only, but through portals)
local cheapPortalTrace = TracePlus.PortalCheap(startPos, endPos)
// Full bbox portal trace (entities + portals)
local bboxPortalTrace = TracePlus.PortalBbox(startPos, endPos)
// From player eyes through portals
local eyeTrace = TracePlus.FromEyes.PortalBbox(1000, player)
Portal Setup Requirements
For portal tracing to work correctly:
- For
prop_portal: If created manually with non-zeropairId, set thehealthkeyvalue to matchpairId - For
linked_portal_door: Set themodelkeyvalue to the portal's width and height (e.g., "128 64") - To disable portals for tracing, call
portal.SetTraceIgnore(true)
Practical Example: Smart Laser Turret
LaserTurret <- class {
turretEntity = null
targetEntity = null
laserBeam = null
settings = null
constructor(turretName) {
this.turretEntity = entLib.FindByName(turretName)
this.laserBeam = entLib.FindByName(turretName + "beam")
// Trace settings: ignore triggers, prioritize players
this.settings = TracePlus.Settings.new()
.SetIgnoredClasses(["trigger_"])
.SetPriorityClasses(["player"])
}
function Think() {
local turretPos = turretEntity.GetOrigin()
local turretAngles = turretEntity.GetAngles()
// Get forward direction from angles
local forward = turretEntity.GetForwardVector()
local endPos = turretPos + (forward * 2000)
// Trace with portal support
local trace = TracePlus.PortalBbox(turretPos, endPos, turretEntity, settings)
// Update laser visual
laserBeam.SetKeyValue("targetpoint", trace.GetHitPos())
if (trace.DidHit()) {
local hitEnt = trace.GetEntity()
if (hitEnt && hitEnt.IsPlayer()) {
if (targetEntity != hitEnt) {
targetEntity = hitEnt
printl("Target acquired!")
// do something
}
} else {
targetEntity = null
}
}
return 0.1 // Think every 0.1 seconds
}
}
// Usage
local myTurret = LaserTurret("security_turret_01")
// Connect Think to a logic_timer or entity think function. Or use ActionScheduler
ScheduleEvent.AddInterval("TurretThink", myTurret.Think, 0.1, 0, null, myTurret)
Practical Example: Interaction System
InteractionSystem <- class {
lastHighlighted = null
function CheckPlayerLookAt() {
local player = GetPlayerEx()
// Settings: only hit interactable objects
local settings = TracePlus.Settings.new()
.SetCollisionFilter(function(ent, note) {
// Only hit entities with "interactable" in user data
return ent.GetUserData("interactable") != null
})
local trace = TracePlus.FromEyes.Bbox(200, player, null, settings)
if (trace.DidHit()) {
local hitEnt = trace.GetEntity()
if (hitEnt) {
if (!macros.IsEqual(hitEnt, lastHighlighted)) {
// Unhighlight previous
if (lastHighlighted) {
lastHighlighted.SetColor("255 255 255")
}
// Highlight new
hitEnt.SetColor("255 255 0")
lastHighlighted = hitEnt
printl("Looking at: " + hitEnt.GetName())
}
}
} else {
// Not looking at anything
if (lastHighlighted) {
lastHighlighted.SetColor("255 255 255")
lastHighlighted = null
}
}
return 0.05 // Check frequently for responsive highlighting
}
}
// Mark entities as interactable
local button = entLib.FindByName("puzzle_button")
button.SetUserData("interactable", true)
local lever = entLib.FindByName("puzzle_lever")
lever.SetUserData("interactable", true)
Performance Considerations
TracePlus.Bbox is more expensive than TraceLine because it tests bounding box intersections for every entity near the ray path. However, it's heavily optimized with:
- Spatial culling: Only tests entities within the trace's bounding region
- Early termination: Stops as soon as the closest hit is found
- Caching: Entity bounding boxes are cached and reused
- Segmented search: Splits long traces into segments for efficiency
Performance Tip: If you're tracing frequently (e.g., in a Think function), use TracePlus.Cheap when possible and only use Bbox when you actually need entity detection. For laser beams that need constant checks, consider caching results or only updating when entities move.
Best Practices
- Use
TracePlus.Bboxwhen you need entity detection - Use
TracePlus.Cheapwhen you only need world geometry (it's faster) - Always ignore the entity you're tracing from (e.g., player, turret)
- Use
FromEyesshortcuts for player view traces - Use
Settingsto filter out irrelevant entities - Cache trace results when possible instead of recomputing every frame
- ❌ Don't use
Bboxin performance-critical loops without caching