Свет и тени 2D: рабочий пример и маски
PointLight2D, заслонитель и проверяемые причины отсутствия света или тени.
Что получится
Светлое пятно освещает 2D-меш, а вертикальная преграда отбрасывает тень вправо. Текстура света и геометрия создаются кодом. В примере нет физических тел: картинка преграды и заслонитель света не создают коллизию.
Нужен C#-проект Reckfell.NET.Sdk 1.0.0, net10.0 и рендерер Compatibility. Сохраните три файла ниже, выполните dotnet build и прикрепите CanvasLightingExample.cs к корню Entity2D уровня .rscene. Запустите уровень. Для окна игры пример задаёт логический Viewport 320×240 во время запуска; это не меняет сохранённые настройки проекта.
CanvasMeshes.cs
using System;
using Reckfell;
public static class CanvasMeshes
{
public static ArrayMesh Quad(Vector2 size)
{
if (!size.IsFinite() || size.X <= 0 || size.Y <= 0)
throw new ArgumentOutOfRangeException(nameof(size));
Vector2 half = size * 0.5f;
return Build(
new[] { new Vector2(-half.X, -half.Y), new Vector2(half.X, -half.Y), half, new Vector2(-half.X, half.Y) },
new[] { Vector2.Zero, Vector2.Right, Vector2.One, Vector2.Down },
new[] { 0, 1, 2, 0, 2, 3 });
}
public static ArrayMesh Triangle() => Build(
new[] { new Vector2(-45, -40), new Vector2(50, -17), new Vector2(-20, 45) },
new[] { Vector2.Zero, Vector2.Right, Vector2.Down },
new[] { 0, 1, 2 });
private static ArrayMesh Build(Vector2[] vertices, Vector2[] uv, int[] indices)
{
var arrays = new Reckfell.Collections.Array();
arrays.Resize((int)Mesh.ArrayType.Max);
arrays[(int)Mesh.ArrayType.Vertex] = vertices;
arrays[(int)Mesh.ArrayType.TexUV] = uv;
arrays[(int)Mesh.ArrayType.Index] = indices;
var mesh = new ArrayMesh();
mesh.AddSurfaceFromArrays(Mesh.PrimitiveType.Triangles, arrays);
return mesh;
}
}CanvasLampTexture.cs
using Reckfell;
public static class CanvasLampTexture
{
public static ImageTexture Create()
{
using var image = Image.CreateEmpty(256, 256, false, Image.Format.Rgba8);
for (int y = 0; y < 256; y++)
for (int x = 0; x < 256; x++)
{
float radius = new Vector2(x - 127.5f, y - 127.5f).Length() / 128;
float strength = Mathf.Max(1 - radius, 0);
image.SetPixel(x, y, new Color(strength, strength, strength, 1));
}
return ImageTexture.CreateFromImage(image);
}
}CanvasLightingExample.cs
using Reckfell;
public partial class CanvasLightingExample : Entity2D
{
[Export] public Viewport.Msaa Antialiasing { get; set; } = Viewport.Msaa.Disabled;
[Export] public bool FollowPointer { get; set; }
public MeshInstance2D Ground { get; private set; } = null!;
public MeshInstance2D Block { get; private set; } = null!;
public PointLight2D Lamp { get; private set; } = null!;
public LightOccluder2D Occluder { get; private set; } = null!;
public CanvasModulate Ambient { get; private set; } = null!;
public override void _Ready()
{
if (GetViewport() is Window window)
{
window.ContentScaleSize = new Vector2I(320, 240);
window.ContentScaleMode = Window.ContentScaleModeEnum.Viewport;
}
GetViewport().Msaa2D = Antialiasing;
Ambient = new CanvasModulate { Color = new Color(0.06f, 0.07f, 0.09f) };
AddChild(Ambient);
Ground = new MeshInstance2D
{
Mesh = CanvasMeshes.Quad(new Vector2(320, 240)),
Position = new Vector2(160, 120),
LightMask = 1
};
AddChild(Ground);
Block = new MeshInstance2D
{
Mesh = CanvasMeshes.Quad(new Vector2(12, 140)),
Position = new Vector2(170, 120),
Modulate = new Color(0.3f, 0.35f, 0.4f),
ZIndex = 1
};
AddChild(Block);
Occluder = new LightOccluder2D
{
Position = Block.Position,
OccluderLightMask = 1,
Occluder = new OccluderPolygon2D
{
Closed = true,
CullMode = OccluderPolygon2D.CullModeEnum.Disabled,
Polygon = new[] { new Vector2(-6, -70), new Vector2(6, -70), new Vector2(6, 70), new Vector2(-6, 70) }
}
};
AddChild(Occluder);
Lamp = new PointLight2D
{
Position = new Vector2(100, 120),
Texture = CanvasLampTexture.Create(),
TextureScale = 1.5f,
Energy = 1,
RangeItemCullMask = 1,
ShadowItemCullMask = 1,
ShadowEnabled = true
};
AddChild(Lamp);
}
public override void _Process(double delta)
{
if (FollowPointer) Lamp.Position = GetGlobalMousePosition();
}
}За что отвечает каждый объект
| Объект | Назначение |
|---|---|
| Ground и Block, MeshInstance2D | Видимая поверхность и преграда |
| CanvasModulate | Затемняет canvas, чтобы вклад света был заметен |
| PointLight2D | Свет с текстурой формы, Energy и масштабом TextureScale |
| LightOccluder2D | Положение заслонителя света |
| OccluderPolygon2D | Замкнутый контур заслонителя; не рисует преграду сам |
ShadowEnabled должен быть включён на источнике. В примере Polygon задаётся относительно Occluder.Position=(170,120), а не в мировых координатах. Контур совпадает с видимой преградой размером 12×140. CullMode=Disabled делает его двусторонним и убирает зависимость этого примера от направления обхода вершин.
CanvasModulate не создаёт источник света. У PointLight2D должна быть Texture: чёрный край нашей радиальной текстуры даёт слабый вклад, светлый центр — сильный. Замена Energy не исправляет неверные маски, пустой контур или материал Unshaded.
Разделить маски освещения и тени
Все значения в примере равны 1. Это битовые маски, а не номера слоёв: 1 включает первый бит, 2 — второй, 3 — оба. Совпадение означает ненулевое пересечение битов, не равенство целых чисел.
| Проверка для PointLight2D | Сопоставляемые значения |
|---|---|
| Получатель принимает свет | RangeItemCullMask источника и CanvasItem.LightMask получателя |
| Заслонитель участвует в тени | ShadowItemCullMask источника и OccluderLightMask заслонителя |
| Получатель принимает тень | ShadowItemCullMask источника и CanvasItem.LightMask получателя |
Поэтому смена только маски заслонителя не всегда даёт ожидаемую тень. Например, при RangeItemCullMask=1, ShadowItemCullMask=2 и OccluderLightMask=2 получателю нужен LightMask=3, чтобы получить свет по первому биту и тень по второму. Это поведение подтверждено кадрами движка. Не подменяйте OccluderLightMask унаследованным LightMask самой Entity-заслонителя.
Маски света не заменяют физические collision layer/mask. ZIndex, диапазон света по Z/canvas-слоям, Visible и принадлежность Viewport проверяются отдельно.
Настроить поведение и материал
FollowPointer=true включает перемещение лампы вслед за курсором в координатах canvas. Это настройка примера; физический ввод в автоматическом прогоне не проверялся. Для фиксированного света оставьте false.
Материал CanvasItemMaterial с LightMode=Unshaded исключает поверхность из обычной реакции на PointLight2D. Если ваш объект не темнеет или не реагирует на свет, проверьте этот режим и пользовательский shader. Для normal map изучите CanvasTexture; контур теней по-прежнему задаётся отдельно. DirectionalLight2D имеет другой охват и ограничения: не переносите правила PointLight2D на него без проверки.
Если результат неверный
- Нет света: проверьте Enabled, Texture, Energy, материал и совпадение маски получателя.
- Нет тени: проверьте ShadowEnabled, непустой OccluderPolygon2D, обе проверки масок теней и положение контура между лампой и поверхностью.
- Тень есть, преграды не видно: LightOccluder2D не рисует изображение; нужен отдельный Sprite2D, Polygon2D или MeshInstance2D.
- Игрок проходит через преграду: добавьте физическое тело и Shape; контур света не является физической формой.
- Всё слишком яркое: проверьте фоновую модуляцию и сумму вкладов нескольких источников, затем меняйте Energy.