New FOV algorithm that works pretty well.

Saved for posterity here, approximately:
https://twitter.com/mcmillen/status/1227326054949408768

GitOrigin-RevId: e960dad1d9241c08dbf1292c6856311d4ebd7a85
This commit is contained in:
Colin McMillen 2020-02-11 17:06:17 -05:00
parent 7cc953a44e
commit 93a5d477bb
3 changed files with 126 additions and 103 deletions

View File

@ -5,13 +5,18 @@ using System;
// https://gamasutra.com/blogs/ItayKeren/20150511/243083/Scroll_Back_The_Theory_and_Practice_of_Cameras_in_SideScrollers.php
namespace SemiColinGames {
class Camera {
private Rectangle bbox = new Rectangle(0, 0, 1920 / 4, 1080 / 4);
// Screen size in pixels is 1920x1080 divided by 4.
private Rectangle bbox = new Rectangle(0, 0, 480, 270);
public int Width { get => bbox.Width; }
public int Height { get => bbox.Height; }
public int Left { get => bbox.Left; }
public int Top { get => bbox.Top; }
public Matrix Projection {
get => Matrix.CreateOrthographicOffCenter(Left, Left + Width, Height, 0, -1, 1);
}
public void Update(Point player, int worldWidth) {
int diff = player.X - bbox.Center.X;
if (Math.Abs(diff) > 16) {

View File

@ -11,7 +11,7 @@ namespace SemiColinGames {
Right = 1
};
enum Pose { Walking, Standing, Crouching, Stretching, SwordSwing, Jumping };
public enum Pose { Walking, Standing, Crouching, Stretching, SwordSwing, Jumping };
private const int moveSpeed = 180;
private const int jumpSpeed = -600;
@ -46,6 +46,15 @@ namespace SemiColinGames {
this.texture = texture;
}
// TODO: just make Facing an int.
public int GetFacing {
get { return (int) facing; }
}
public Pose GetPose {
get { return pose; }
}
public Point Position { get { return position; } }
public void Update(float modelTime, History<Input> input, AABB[] collisionTargets) {
@ -147,102 +156,15 @@ namespace SemiColinGames {
} else {
pose = Pose.Standing;
}
DrawSightLines(collisionTargets);
}
Vector2 Rotate(Vector2 point, float angle) {
float cos = FMath.Cos(angle);
float sin = FMath.Sin(angle);
return new Vector2(
point.X * cos - point.Y * sin,
point.Y * cos + point.X * sin);
}
bool PointInCone(
float visionRangeSq, float fovCos, Vector2 eyePos, Vector2 direction, Vector2 test) {
Vector2 delta = Vector2.Subtract(test, eyePos);
if (delta.LengthSquared() > visionRangeSq) {
return false;
public Vector2 EyePosition {
get {
bool walking = pose == Pose.Walking || pose == Pose.Jumping;
Vector2 eyeOffset = walking ? eyeOffsetWalking : eyeOffsetStanding;
return Vector2.Add(
Position.ToVector2(), new Vector2(eyeOffset.X * (int) facing, eyeOffset.Y));
}
float dot = Vector2.Dot(Vector2.Normalize(direction), Vector2.Normalize(delta));
return dot > fovCos;
}
void DrawSightLines(AABB[] collisionTargets) {
float fov = FMath.DegToRad(45);
float fovCos = FMath.Cos(fov);
Color color = Color.LightYellow;
Vector2 eyeOffset = pose == Pose.Walking ? eyeOffsetWalking : eyeOffsetStanding;
Vector2 eyePos = Vector2.Add(
Position.ToVector2(), new Vector2(eyeOffset.X * (int) facing, eyeOffset.Y));
float visionRange = 150;
float visionRangeSq = visionRange * visionRange;
Vector2 ray = new Vector2(visionRange * (int) facing, 0);
if (pose == Pose.Stretching) {
ray = Rotate(ray, (int) facing * FMath.DegToRad(-30));
}
if (pose == Pose.Crouching) {
ray = Rotate(ray, (int) facing * FMath.DegToRad(30));
}
Vector2 coneBottom = Rotate(ray, fov);
Vector2 coneTop = Rotate(ray, -fov);
List<Vector2> points = new List<Vector2>();
List<AABB> boxes = new List<AABB>();
points.Add(Vector2.Add(eyePos, coneBottom));
points.Add(Vector2.Add(eyePos, coneTop));
foreach (AABB box in collisionTargets) {
int hitCount = points.Count;
if (PointInCone(visionRangeSq, fovCos, eyePos, ray, box.TopLeft)) {
points.Add(box.TopLeft);
}
if (PointInCone(visionRangeSq, fovCos, eyePos, ray, box.TopRight)) {
points.Add(box.TopRight);
}
if (PointInCone(visionRangeSq, fovCos, eyePos, ray, box.BottomLeft)) {
points.Add(box.BottomLeft);
}
if (PointInCone(visionRangeSq, fovCos, eyePos, ray, box.BottomRight)) {
points.Add(box.BottomRight);
}
if (points.Count > hitCount) {
boxes.Add(box);
Debug.AddRect(box, color);
}
}
HashSet<AABB> boxesSeen = new HashSet<AABB>();
foreach (Vector2 point in points) {
float minTime = 1;
AABB? closestBox = null;
Vector2 delta = Vector2.Subtract(point, eyePos);
foreach (AABB box in boxes) {
Hit? maybeHit = box.IntersectSegment(eyePos, delta);
if (maybeHit != null) {
float time = FMath.Clamp(maybeHit.Value.Time, 0, 1);
Vector2 target = Vector2.Add(eyePos, Vector2.Multiply(delta, time));
Debug.AddLine(eyePos, target, color);
if (time < minTime) {
minTime = time;
closestBox = box;
}
}
}
if (closestBox != null) {
boxesSeen.Add(closestBox.Value);
}
}
foreach (AABB box in boxesSeen) {
Debug.AddRect(box, Color.Orange);
}
Debug.AddLine(eyePos, Vector2.Add(eyePos, ray), Color.Red);
Debug.AddLine(eyePos, Vector2.Add(eyePos, coneTop), Color.Red);
Debug.AddLine(eyePos, Vector2.Add(eyePos, coneBottom), Color.Red);
}
// Returns the desired (dx, dy) for the player to move this frame.

View File

@ -11,7 +11,10 @@ namespace SemiColinGames {
const double TARGET_FRAME_TIME = 1.0 / TARGET_FPS;
readonly GraphicsDeviceManager graphics;
RenderTarget2D renderTarget;
RenderTarget2D sceneTarget;
RenderTarget2D lightingTarget;
BasicEffect lightingEffect;
SpriteBatch spriteBatch;
SpriteFont font;
@ -54,9 +57,22 @@ namespace SemiColinGames {
Debug.Initialize(GraphicsDevice);
renderTarget = new RenderTarget2D(
sceneTarget = new RenderTarget2D(
GraphicsDevice, camera.Width, camera.Height, false /* mipmap */,
GraphicsDevice.PresentationParameters.BackBufferFormat, DepthFormat.Depth24);
lightingTarget = new RenderTarget2D(
GraphicsDevice, camera.Width, camera.Height, false /* mipmap */,
GraphicsDevice.PresentationParameters.BackBufferFormat, DepthFormat.Depth24);
lightingEffect = new BasicEffect(GraphicsDevice);
lightingEffect.World = Matrix.CreateTranslation(0, 0, 0);
lightingEffect.View = Matrix.CreateLookAt(Vector3.Backward, Vector3.Zero, Vector3.Up);
lightingEffect.VertexColorEnabled = true;
RasterizerState rasterizerState = new RasterizerState() {
CullMode = CullMode.None
};
GraphicsDevice.RasterizerState = rasterizerState;
base.Initialize();
}
@ -67,7 +83,7 @@ namespace SemiColinGames {
font = Content.Load<SpriteFont>("font");
player = new Player(Content.Load<Texture2D>("Ninja_Female"));
world = new World(Content.Load<Texture2D>("grassland"), Levels.DEMO);
world = new World(Content.Load<Texture2D>("grassland"), Levels.ONE_ONE);
grasslandBg1 = Content.Load<Texture2D>("grassland_bg1");
grasslandBg2 = Content.Load<Texture2D>("grassland_bg2");
}
@ -131,8 +147,8 @@ namespace SemiColinGames {
Debug.SetFpsText(fpsText);
// Draw scene to RenderTarget.
GraphicsDevice.SetRenderTarget(renderTarget);
// Draw scene to sceneTarget.
GraphicsDevice.SetRenderTarget(sceneTarget);
GraphicsDevice.Clear(Color.CornflowerBlue);
spriteBatch.Begin(SpriteSortMode.Deferred, null, SamplerState.LinearWrap, null, null);
@ -148,7 +164,8 @@ namespace SemiColinGames {
// Set up transformation matrix for drawing world objects.
Matrix transform = Matrix.CreateTranslation(-camera.Left, -camera.Top, 0);
spriteBatch.Begin(SpriteSortMode.Deferred, null, SamplerState.LinearWrap, null, null, null, transform);
spriteBatch.Begin(
SpriteSortMode.Deferred, null, SamplerState.LinearWrap, null, null, null, transform);
// Draw player.
player.Draw(spriteBatch);
@ -162,7 +179,13 @@ namespace SemiColinGames {
// Aaaaand we're done.
spriteBatch.End();
// Draw RenderTarget to screen.
// Draw lighting to lightingTarget.
GraphicsDevice.SetRenderTarget(lightingTarget);
GraphicsDevice.Clear(new Color(0, 0, 0, 0f));
lightingEffect.Projection = camera.Projection;
DrawFov();
// Draw sceneTarget to screen.
GraphicsDevice.SetRenderTarget(null);
GraphicsDevice.Clear(Color.CornflowerBlue);
if (framesToSuppress == 0) {
@ -171,7 +194,8 @@ namespace SemiColinGames {
RasterizerState.CullNone);
Rectangle drawRect = new Rectangle(
0, 0, GraphicsDevice.Viewport.Width, GraphicsDevice.Viewport.Height);
spriteBatch.Draw(renderTarget, drawRect, Color.White);
spriteBatch.Draw(sceneTarget, drawRect, Color.White);
spriteBatch.Draw(lightingTarget, drawRect, Color.White);
// Draw debug toasts.
Debug.DrawToasts(spriteBatch, font);
@ -182,5 +206,77 @@ namespace SemiColinGames {
base.Draw(gameTime);
drawTimer.Stop();
}
private void DrawFov() {
// TODO: DrawIndexedPrimitives
Color color = Color.FromNonPremultiplied(new Vector4(0, 0, 1, 0.6f));
Vector2 eyePos = player.EyePosition;
int numConePoints = 60;
// TODO: don't new[] every frame.
VertexPositionColor[] conePoints = new VertexPositionColor[numConePoints];
float visionRange = 150;
float visionRangeSq = visionRange * visionRange;
float fov = FMath.DegToRad(120);
float fovStep = fov / (numConePoints - 1);
Vector2 ray = new Vector2(visionRange * player.GetFacing, 0);
if (player.GetPose == Player.Pose.Stretching) {
ray = ray.Rotate(player.GetFacing * FMath.DegToRad(-30));
}
if (player.GetPose == Player.Pose.Crouching) {
ray = ray.Rotate(player.GetFacing * FMath.DegToRad(30));
}
for (int i = 0; i < conePoints.Length; i++) {
float angle = -fov / 2 + fovStep * i;
Vector2 rotated = ray.Rotate(angle);
Vector2 closestHit = Vector2.Add(eyePos, rotated);
float hitTime = 1f;
Vector2 halfTileSize = new Vector2(World.TileSize / 2.0f, World.TileSize / 2.0f);
for (int j = 0; j < world.CollisionTargets.Length; j++) {
AABB box = world.CollisionTargets[j];
if (Math.Abs(box.Position.X - player.Position.X) > visionRange + halfTileSize.X) {
continue;
}
Vector2 delta = Vector2.Add(halfTileSize, Vector2.Subtract(box.Position, eyePos));
if (delta.LengthSquared() > visionRangeSq) {
continue;
}
Hit? maybeHit = box.IntersectSegment(eyePos, rotated);
if (maybeHit != null) {
Hit hit = maybeHit.Value;
if (hit.Time < hitTime) {
hitTime = hit.Time;
closestHit = hit.Position;
}
}
}
float tint = 0.6f - hitTime / 2;
Color tinted = Color.FromNonPremultiplied(new Vector4(0, 0, 1, tint));
conePoints[i] = new VertexPositionColor(new Vector3(closestHit, 0), tinted);
}
// TODO: don't new[] every frame.
VertexPositionColor[] vertices = new VertexPositionColor[numConePoints * 3];
VertexPositionColor eyeVertex = new VertexPositionColor(new Vector3(eyePos, 0), color);
for (int i = 0; i < numConePoints - 1; i++) {
vertices[i * 3] = eyeVertex;
vertices[i * 3 + 1] = conePoints[i];
vertices[i * 3 + 2] = conePoints[i + 1];
}
VertexBuffer vertexBuffer = new VertexBuffer(
GraphicsDevice, typeof(VertexPositionColor), vertices.Length, BufferUsage.WriteOnly);
vertexBuffer.SetData<VertexPositionColor>(vertices);
GraphicsDevice.SetVertexBuffer(vertexBuffer);
foreach (EffectPass pass in lightingEffect.CurrentTechnique.Passes) {
pass.Apply();
GraphicsDevice.DrawPrimitives(PrimitiveType.TriangleList, 0, vertices.Length / 3);
}
}
}
}