feat: implement sprite_light uber-shader with dynamic lighting and animated outline

This commit is contained in:
PeaAshMeter 2026-04-23 19:34:35 +03:00
parent 254e94fc29
commit cb53fa8d88
2 changed files with 44 additions and 25 deletions

View File

@ -12,6 +12,11 @@ extern vec2 sprite_pos; // Мировая позиция спрайта (в ме
extern vec3 ambient; // Эмбиентное освещение extern vec3 ambient; // Эмбиентное освещение
extern vec3 sky; // Цвет неба extern vec3 sky; // Цвет неба
// Параметры выделения
extern bool is_selected;
extern vec2 tex_size;
extern float time;
// Функция для имитации easing.easeInSine // Функция для имитации easing.easeInSine
float easeInSine(float x) { float easeInSine(float x) {
return 1.0 - cos((x * 3.14159) / 2.0); return 1.0 - cos((x * 3.14159) / 2.0);
@ -19,20 +24,42 @@ float easeInSine(float x) {
vec4 effect(vec4 vcolor, Image tex, vec2 texture_coords, vec2 screen_coords) vec4 effect(vec4 vcolor, Image tex, vec2 texture_coords, vec2 screen_coords)
{ {
// Расчет базового освещения мира: персонаж освещается смесью неба (Sky) и отраженного света (Ambient).
// Это гарантирует, что цвета персонажа всегда соответствуют цветовой гамме текущего времени суток.
vec3 baseLight = ambient + (vec3(1.0) - ambient) * sky;
// Десатурация базового света на 30%. Это необходимо, чтобы яркие цвета неба (например, на закате)
// не перекрывали собственные цвета персонажа слишком сильно, сохраняя его узнаваемость.
float luma = dot(baseLight, vec3(0.2126, 0.7152, 0.0722)); // https://en.wikipedia.org/wiki/Relative_luminance
vec3 characterBaseLight = mix(baseLight, vec3(luma), 0.3);
vec4 texColor = Texel(tex, texture_coords); vec4 texColor = Texel(tex, texture_coords);
// ЛОГИКА ОБВОДКИ (Outline)
// Мы выполняем ее до расчетов освещения. Если пиксель прозрачный и объект выбран,
// проверяем соседей, чтобы понять, не граница ли это.
// Обводка рисуется "самосветящейся", чтобы выделение было видно даже в полной темноте.
if (is_selected && texColor.a <= 0.0) {
float maxAlpha = 0.0;
// Проверяем соседние пиксели (квадратом 3x3)
for (float x = -1.0; x <= 1.0; x++) {
for (float y = -1.0; y <= 1.0; y++) {
if (x == 0.0 && y == 0.0) continue;
vec2 offset = vec2(x, y) / tex_size;
maxAlpha = max(maxAlpha, Texel(tex, texture_coords + offset).a);
}
}
if (maxAlpha > 0.0) {
// Эффект пульсации и "бегущей волны" из оригинального шейдера outline.glsl
float modY = (0.75 + sin(time) * 0.25) * (0.5 + cos(texture_coords.y * 10.0 + time * 2.0) * 0.5);
return vec4(vec3(modY, 0.2 * sin(time) + 0.5, 0.5), 1.0);
}
}
if (texColor.a == 0.0) { if (texColor.a == 0.0) {
return vec4(0.0); return vec4(0.0);
} }
// Расчет базового освещения мира: персонаж освещается смесью неба (Sky) и отраженного света (Ambient).
vec3 baseLight = ambient + (vec3(1.0) - ambient) * sky;
// Десатурация базового света на 30%.
float luma = dot(baseLight, vec3(0.2126, 0.7152, 0.0722));
vec3 characterBaseLight = mix(baseLight, vec3(luma), 0.3);
vec3 pointLight = vec3(0.0); vec3 pointLight = vec3(0.0);
for (int i = 0; i < num_lights; i++) { for (int i = 0; i < num_lights; i++) {
@ -42,27 +69,17 @@ vec4 effect(vec4 vcolor, Image tex, vec2 texture_coords, vec2 screen_coords)
float dist = length(lightVec); float dist = length(lightVec);
// Плавное затухание света по радиусу. // Плавное затухание света по радиусу.
// Свет начинает гаснуть на 20% дистанции до края и полностью исчезает на границе радиуса.
// Это обеспечивает мягкий край света.
float radiusFalloff = smoothstep(radius, radius * 0.2, dist); float radiusFalloff = smoothstep(radius, radius * 0.2, dist);
// Реализация псевдо-проекции (3D-эффект): // Реализация псевдо-проекции (3D-эффект):
// Чтобы избежать резких скачков яркости при пересечении линии ног персонажа,
// мы используем плавный переход веса освещенности.
// Если источник света ниже персонажа (lightVec.y > 0), он считается "перед" ним.
// Если выше (lightVec.y < 0) — "за спиной".
// Мы плавно переходим от 0% до 100% мощности в диапазоне от -2.0 до 0.5 метров по вертикали.
float frontWeight = smoothstep(-2.0, 0.5, lightVec.y); float frontWeight = smoothstep(-2.0, 0.5, lightVec.y);
float attenuation = radiusFalloff * frontWeight; float attenuation = radiusFalloff * frontWeight;
pointLight += lights[i].color * attenuation; pointLight += lights[i].color * attenuation;
} }
// Ограничиваем суммарную яркость точечных источников, чтобы избежать пересветов в белое
pointLight = clamp(pointLight, 0.0, 1.0); pointLight = clamp(pointLight, 0.0, 1.0);
// Финальный расчет цвета: // Финальный расчет цвета:
// Мы берем текстуру персонажа и умножаем ее на сумму базового света мира и всех точечных источников.
// Это создает эффект, где персонаж одновременно "вписан" в атмосферу уровня и реагирует на динамические огни.
return vec4(texColor.rgb * (characterBaseLight + pointLight), texColor.a); return vec4(texColor.rgb * (characterBaseLight + pointLight), texColor.a);
} }

View File

@ -83,12 +83,20 @@ function sprite:draw()
local lightShader = Tree.assets.files.shaders.sprite_light local lightShader = Tree.assets.files.shaders.sprite_light
local weather = Tree.level.weather local weather = Tree.level.weather
local numLights = math.min(#lightsData, 8) local numLights = math.min(#lightsData, 8)
local isSelected = (Tree.level.selector.id == self.owner.id)
lightShader:send("sprite_pos", { position.x, position.y }) lightShader:send("sprite_pos", { position.x, position.y })
lightShader:send("sky", { weather.skyLight.x, weather.skyLight.y, weather.skyLight.z }) lightShader:send("sky", { weather.skyLight.x, weather.skyLight.y, weather.skyLight.z })
lightShader:send("ambient", { weather.ambientLight.x, weather.ambientLight.y, weather.ambientLight.z }) lightShader:send("ambient", { weather.ambientLight.x, weather.ambientLight.y, weather.ambientLight.z })
lightShader:send("num_lights", numLights) lightShader:send("num_lights", numLights)
lightShader:send("is_selected", isSelected)
if isSelected then
local sheet = self.sheets[self.state]
lightShader:send("tex_size", { sheet:getWidth(), sheet:getHeight() })
lightShader:send("time", love.timer:getTime())
end
for i = 1, numLights do for i = 1, numLights do
local l = lightsData[i] local l = lightsData[i]
local idx = i - 1 local idx = i - 1
@ -100,12 +108,6 @@ function sprite:draw()
love.graphics.setShader(lightShader) love.graphics.setShader(lightShader)
if Tree.level.selector.id == self.owner.id then
-- Если выбран, то рисуем еще и обводку?
-- В LÖVE нельзя поставить два шейдера сразу через setShader.
-- Для простоты пока оставим только свет, либо нужно комбинировать шейдеры.
end
self.animationTable[self.state]:draw(self.sheets[self.state], self.animationTable[self.state]:draw(self.sheets[self.state],
position.x, position.x,
position.y, nil, 1 / ppm * self.side, 1 / ppm, self.manifest.base.x, self.manifest.base.y) position.y, nil, 1 / ppm * self.side, 1 / ppm, self.manifest.base.x, self.manifest.base.y)