Obstacle-aware camera: SpringArm3D
Set up camera orbit and distance, exclude the character body and distinguish shapes, margins and layers.
Result
The camera orbits a stationary object and moves inward before a wall. Left/right arrows rotate the arm; up/down change requested distance between 2 and 40. This is a camera example, without character movement, mouse input or smooth following.
SpringArm3D queries along local +Z and moves its direct Entity3D children to the resulting distance. Camera3D looks along its own -Z, so a camera on positive Z looks back toward the target. Do not make the character body a direct arm child: the arm would move it too.
Run the complete example
Use a Reckfell.NET.Sdk 1.0.0 C# project targeting net10.0 and a .rscene level rooted in Entity3D. Save all three files below, run dotnet build and attach ViewLab3D.cs to the root. Enable exported UseWall before running to test an obstacle. Its default is false so the object and distance-selected models remain visible without a wall.
The project needs ui_left, ui_right, ui_up and ui_down actions bound to arrow keys. Add them in Input Map if missing. Code creates the resources, camera, label, floor and collision; no external models are needed. This example does not verify exact editor-interface interactions.
LodVisual3D.cs
using Reckfell;
public partial class LodVisual3D : MeshInstance3D
{
public int ProcessTicks { get; private set; }
public override void _Process(double delta) => ProcessTicks++;
}ViewLabMeshes.cs
using Reckfell;
public static class ViewLabMeshes
{
public static StandardMaterial3D Material(Color color) => new()
{
AlbedoColor = color,
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded
};
public static MeshInstance3D Box(Vector3 size, Color color) => new()
{
Mesh = new BoxMesh { Size = size },
MaterialOverride = Material(color)
};
public static TextMesh Text(string text) => new()
{
Text = text,
FontSize = 64,
PixelSize = 0.01f,
Depth = 0.15f
};
}ViewLab3D.cs
using Reckfell;
public partial class ViewLab3D : Entity3D
{
[Export] public float Distance { get; set; } = 6;
[Export] public bool UseWall { get; set; }
public SpringArm3D Boom { get; private set; } = null!;
public Camera3D Camera { get; private set; } = null!;
public LodVisual3D NearModel { get; private set; } = null!;
public LodVisual3D FarModel { get; private set; } = null!;
public Label3D Caption { get; private set; } = null!;
public MeshInstance3D Floor { get; private set; } = null!;
public StaticBody3D ActorBody { get; private set; } = null!;
public StaticBody3D? WallBody { get; private set; }
public override void _Ready()
{
ActorBody = new StaticBody3D { Position = new Vector3(0, 1, 0) };
AddChild(ActorBody);
ActorBody.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(1.4f, 2, 1.4f) } });
NearModel = new LodVisual3D
{
Position = ActorBody.Position,
Mesh = new SphereMesh { Radius = 0.7f, Height = 1.4f, RadialSegments = 64, Rings = 32 },
MaterialOverride = ViewLabMeshes.Material(Colors.White),
VisibilityRangeEnd = 12
};
FarModel = new LodVisual3D
{
Position = ActorBody.Position,
Mesh = new BoxMesh { Size = new Vector3(1.2f, 1.2f, 1.2f) },
MaterialOverride = ViewLabMeshes.Material(new Color(0.08f, 0.25f, 1)),
VisibilityRangeBegin = 12
};
AddChild(NearModel);
AddChild(FarModel);
Floor = new MeshInstance3D
{
Mesh = new PlaneMesh { Size = new Vector2(20, 20) },
MaterialOverride = ViewLabMeshes.Material(new Color(0.1f, 0.12f, 0.15f))
};
AddChild(Floor);
Caption = new Label3D
{
Text = "Reckfell",
Position = new Vector3(0, 2.1f, 0),
FontSize = 48,
PixelSize = 0.008f,
Billboard = BaseMaterial3D.BillboardModeEnum.Enabled,
Modulate = new Color(0.6f, 0.75f, 1)
};
AddChild(Caption);
if (UseWall)
{
WallBody = new StaticBody3D { Position = new Vector3(0, 1, 3) };
AddChild(WallBody);
WallBody.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(6, 4, 0.4f) } });
WallBody.AddChild(ViewLabMeshes.Box(new Vector3(6, 4, 0.4f), new Color(0.1f, 0.12f, 0.15f)));
}
Boom = new SpringArm3D
{
Position = new Vector3(0, 1, 0),
Shape = new SphereShape3D { Radius = 0.2f },
CollisionMask = 1,
Margin = 0.15f
};
AddChild(Boom);
Boom.AddExcludedObject(ActorBody.GetRid());
Camera = new Camera3D { Current = true, Near = 0.05f, Fov = 60 };
Boom.AddChild(Camera);
SetDistance(Distance);
}
public void SetDistance(float distance)
{
if (!float.IsFinite(distance)) return;
Distance = Mathf.Clamp(distance, 2, 40);
Boom.SpringLength = Distance;
}
public override void _PhysicsProcess(double delta)
{
SetDistance(Distance + Input.GetAxis("ui_up", "ui_down") * 8 * (float)delta);
Boom.RotateY(Input.GetAxis("ui_left", "ui_right") * 1.5f * (float)delta);
}
}Call SetDistance after Boom is created in _Ready. Before startup, set exported Distance instead. Changing UseWall during play does not create a wall; the example reads it only during level creation.
Distinguish the three query paths
| Condition | Query | Detail |
|---|---|---|
| Shape is assigned | Shape cast | The sample uses a radius-0.2 sphere; its dimensions determine clearance |
| Shape=null, direct Camera3D child exists | Automatic camera shape | A camera inside another Entity3D is no longer a direct child |
| Shape=null, no direct camera | Ray | Only this branch subtracts Margin in the verified version |
The verified explicit-shape and automatic-camera-shape branches do not apply Margin. If the sphere still gets too close, choose suitable Shape dimensions and camera Near instead of repeatedly increasing Margin. A large near clip or small shape can leave visible corner issues. This example does not prove protection against every fast movement or complex obstacle.
GetHitLength reads the last physics result. Assigning SpringLength does not immediately produce a new value; wait for a physics update. Keep the arm and parent scales at one for predictable distance units.
Exclude the target and configure layers
CollisionMask=1 queries the first physics layer. A wall needs a CollisionShape3D and matching CollisionLayer; a MeshInstance3D alone cannot stop the camera. CollisionMask=0 matches no layers: useful for diagnosis, but obstacle protection is disabled.
Boom.AddExcludedObject(ActorBody.GetRid()) excludes the target body. Supply its physics RID, not GetInstanceId() or a mesh RID. The exclusion affects arm queries, not character collisions with the world.
Every direct Entity3D child receives a position on the arm axis; lateral local offsets are not retained. For an over-the-shoulder layout, offset an appropriate parent pivot rather than relying on Camera.Position.X, which the arm overwrites.
Check the result and diagnose failures
With UseWall=true and requested distance 6, the camera stays before the wall and GetHitLength is below SpringLength. Rotate toward open space: it moves outward again. Native checks exercise the explicit shape, automatic camera and ray separately; Margin-dependent shortening is verified only for the ray.
| Symptom | Check |
|---|---|
| Camera passes through a wall | Wall physics shape, CollisionLayer, CollisionMask and arm processing |
| Camera collides with its own target | Target body RID exclusion |
| Margin appears ineffective | Query branch; this is expected with Shape |
| Another object moves unexpectedly | Other direct Entity3D children of the arm |
| Arrow keys do nothing | Input Map actions and game-window focus |
| Far model never appears | Disable UseWall before startup; the wall shortens actual camera distance |
Continue with world text, distance visibility and SpringArm3D API.