From 1ad38c31031c31561f8be931f027255cb0c790f3 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Thu, 23 Apr 2026 17:34:31 +0300 Subject: [PATCH 1/5] feat: implement RenderQueue, SpriteBatch for tiles and Low-Res rendering for lights/shadows --- lib/character/behaviors/light.lua | 27 +++-- lib/character/behaviors/shadowcaster.lua | 66 ++++++------ lib/character/behaviors/sprite.lua | 33 +++--- lib/level/grid/tile_grid.lua | 29 ++++-- lib/level/level.lua | 6 +- lib/level/render.lua | 126 ++++++++++++++++++----- lib/spell/spell.lua | 47 ++++----- main.lua | 2 +- 8 files changed, 206 insertions(+), 130 deletions(-) diff --git a/lib/character/behaviors/light.lua b/lib/character/behaviors/light.lua index 1f8b9d8..dd26adc 100644 --- a/lib/character/behaviors/light.lua +++ b/lib/character/behaviors/light.lua @@ -32,22 +32,19 @@ function behavior:draw() local positioned = self.owner:has(Tree.behaviors.positioned) if not positioned then return end - love.graphics.setBlendMode("add", "premultiplied") - Tree.level.camera:attach() - love.graphics.setCanvas(Tree.level.render.textures.lightLayer) - local shader = Tree.assets.files.shaders.light - shader:send("color", { self.color.x, self.color.y, self.color.z }) - shader:send("time", love.timer.getTime() + self.seed) - love.graphics.setShader(shader) - love.graphics.draw(Tree.assets.files.masks.circle128, positioned.position.x - self.intensity / 2, - positioned.position.y - self.intensity / 2, 0, self.intensity / 128, - self.intensity / 128) + Tree.level.render:enqueue(Tree.level.render.LAYERS.LIGHT, positioned.position.y, function() + love.graphics.setBlendMode("add", "premultiplied") + local shader = Tree.assets.files.shaders.light + shader:send("color", { self.color.x, self.color.y, self.color.z }) + shader:send("time", love.timer:getTime() + self.seed) + love.graphics.setShader(shader) + love.graphics.draw(Tree.assets.files.masks.circle128, positioned.position.x - self.intensity / 2, + positioned.position.y - self.intensity / 2, 0, self.intensity / 128, + self.intensity / 128) - love.graphics.setBlendMode("alpha") - - love.graphics.setShader() - love.graphics.setCanvas() - Tree.level.camera:detach() + love.graphics.setShader() + love.graphics.setBlendMode("alpha") + end) end return behavior diff --git a/lib/character/behaviors/shadowcaster.lua b/lib/character/behaviors/shadowcaster.lua index c343cf8..904322e 100644 --- a/lib/character/behaviors/shadowcaster.lua +++ b/lib/character/behaviors/shadowcaster.lua @@ -10,10 +10,7 @@ function behavior:draw() local sprite = self.owner:has(Tree.behaviors.sprite) local positioned = self.owner:has(Tree.behaviors.positioned) if not positioned then return end - if not sprite then - love.graphics.setCanvas() - return - end + if not sprite then return end local ppm = Tree.level.camera.pixelsPerMeter local position = positioned.position + Vec3 { 0.5, 0.5 } @@ -25,41 +22,40 @@ function behavior:draw() table.insert(lights, Tree.level.characters[id]) end + -- 1. Эллипс тени + Tree.level.render:enqueue(Tree.level.render.LAYERS.SHADOW, position.y, function() + love.graphics.push() + love.graphics.setColor(0, 0, 0, 1) + love.graphics.translate(position.x, position.y) + love.graphics.ellipse("fill", 0, 0, sprite.manifest.size / 2, sprite.manifest.size / 2 * math.cos(math.pi / 4)) + love.graphics.pop() + end) - Tree.level.camera:attach() - love.graphics.setCanvas(Tree.level.render.textures.shadowLayer) - love.graphics.push() - love.graphics.setColor(0, 0, 0, 1) - love.graphics.translate(position.x, position.y) - love.graphics.ellipse("fill", 0, 0, sprite.manifest.size / 2, sprite.manifest.size / 2 * math.cos(math.pi / 4)) - love.graphics.pop() + -- 2. Свет на спрайте + if #lights > 0 then + Tree.level.render:enqueue(Tree.level.render.LAYERS.SPRITE_LIGHT, position.y, function() + love.graphics.setBlendMode("add") + for _, light in ipairs(lights) do + local lightPos = light:has(Tree.behaviors.positioned).position + local lightVec = lightPos - position + local lightColor = light:has(Tree.behaviors.light).color + if lightPos.y > position.y then + love.graphics.setColor(lightColor.x, lightColor.y, lightColor.z, + 1 - 0.3 * lightVec:length()) + elseif position.y - lightPos.y < 3 then + love.graphics.setColor(lightColor.x, lightColor.y, lightColor.z, + (1 - easing.easeInSine((position.y - lightPos.y))) - 0.3 * lightVec:length()) + end - - love.graphics.setCanvas(Tree.level.render.textures.spriteLightLayer) - love.graphics.setBlendMode("add") - for _, light in ipairs(lights) do - local lightPos = light:has(Tree.behaviors.positioned).position - local lightVec = lightPos - position - - local lightColor = light:has(Tree.behaviors.light).color - if lightPos.y > position.y then - love.graphics.setColor(lightColor.x, lightColor.y, lightColor.z, - 1 - 0.3 * lightVec:length()) - elseif position.y - lightPos.y < 3 then - love.graphics.setColor(lightColor.x, lightColor.y, lightColor.z, - (1 - easing.easeInSine((position.y - lightPos.y))) - 0.3 * lightVec:length()) - end - - sprite.animationTable[sprite.state]:draw(sprite.sheets[sprite.state], - position.x, - position.y, nil, 1 / ppm * sprite.side, 1 / ppm, sprite.manifest.base.x, sprite.manifest.base.y) + sprite.animationTable[sprite.state]:draw(sprite.sheets[sprite.state], + position.x, + position.y, nil, 1 / ppm * sprite.side, 1 / ppm, sprite.manifest.base.x, sprite.manifest.base.y) + end + love.graphics.setBlendMode("alpha") + love.graphics.setColor(1, 1, 1) + end) end - love.graphics.setBlendMode("alpha") - - Tree.level.camera:detach() - love.graphics.setColor(1, 1, 1) - love.graphics.setCanvas() end return behavior diff --git a/lib/character/behaviors/sprite.lua b/lib/character/behaviors/sprite.lua index 0325a4b..6625b01 100644 --- a/lib/character/behaviors/sprite.lua +++ b/lib/character/behaviors/sprite.lua @@ -49,25 +49,22 @@ function sprite:draw() local ppm = Tree.level.camera.pixelsPerMeter local position = pos.position + Vec3 { 0.5, 0.5 } - love.graphics.setCanvas(Tree.level.render.textures.spriteLayer) - Tree.level.camera:attach() + Tree.level.render:enqueue(Tree.level.render.LAYERS.SPRITE, position.y, function() + love.graphics.setColor(1, 1, 1) + if Tree.level.selector.id == self.owner.id then + local texW, texH = self.sheets[self.state]:getWidth(), + self.sheets[self.state]:getHeight() + local shader = Tree.assets.files.shaders.outline + shader:send("texSize", { texW, texH }) + shader:send("time", love.timer:getTime()) + love.graphics.setShader(shader) + end + self.animationTable[self.state]:draw(self.sheets[self.state], + position.x, + position.y, nil, 1 / ppm * self.side, 1 / ppm, self.manifest.base.x, self.manifest.base.y) - love.graphics.setColor(1, 1, 1) - if Tree.level.selector.id == self.owner.id then - local texW, texH = self.sheets[self.state]:getWidth(), - self.sheets[self.state]:getHeight() - local shader = Tree.assets.files.shaders.outline - shader:send("texSize", { texW, texH }) - shader:send("time", love.timer:getTime()) - love.graphics.setShader(shader) - end - self.animationTable[self.state]:draw(self.sheets[self.state], - position.x, - position.y, nil, 1 / ppm * self.side, 1 / ppm, self.manifest.base.x, self.manifest.base.y) - - love.graphics.setShader() - Tree.level.camera:detach() - love.graphics.setCanvas() + love.graphics.setShader() + end) end ) end diff --git a/lib/level/grid/tile_grid.lua b/lib/level/grid/tile_grid.lua index 1a89a10..8982abb 100644 --- a/lib/level/grid/tile_grid.lua +++ b/lib/level/grid/tile_grid.lua @@ -9,17 +9,32 @@ map.__index = map --- @param size? Vec3 local function new(type, template, size) local tMap = require('lib.level.' .. type).new(template, size) - return setmetatable({ __grid = tMap }, map) + local grid = setmetatable({ __grid = tMap }, map) + grid:refreshBatch() + return grid +end + +function map:refreshBatch() + -- Находим атлас первого попавшегося тайла (предполагаем, что он один для всех) + local _, firstTile = next(self.__grid) + if not firstTile then return end + + local atlas = firstTile.atlasData.atlas + local count = 0 + for _ in pairs(self.__grid) do count = count + 1 end + + self.batch = love.graphics.newSpriteBatch(atlas, count) + for _, tile in pairs(self.__grid) do + -- 1/32 это масштаб, так как размер тайла в мире 1x1 метр, а в атласе 32x32 пикселя + self.batch:add(tile.atlasData.quad, tile.position.x, tile.position.y, 0, 1 / 32, 1 / 32) + end end function map:draw() - love.graphics.setCanvas(Tree.level.render.textures.floorLayer) - Tree.level.camera:attach() - utils.each(self.__grid, function(el) - el:draw() + if not self.batch then return end + Tree.level.render:enqueue(Tree.level.render.LAYERS.FLOOR, 0, function() + love.graphics.draw(self.batch) end) - Tree.level.camera:detach() - love.graphics.setCanvas() end return { new = new } diff --git a/lib/level/level.lua b/lib/level/level.lua index 67945eb..16238e7 100644 --- a/lib/level/level.lua +++ b/lib/level/level.lua @@ -56,9 +56,9 @@ end function level:draw() self.render:clear() self.tileGrid:draw() - while not self.characterGrid.yOrderQueue:is_empty() do -- по сути это сортировка кучей за n log n - self.characterGrid.yOrderQueue:pop():draw() - end + utils.each(self.characters, function(char) + char:draw() + end) self.render:draw() end diff --git a/lib/level/render.lua b/lib/level/render.lua index b0901d8..ae23047 100644 --- a/lib/level/render.lua +++ b/lib/level/render.lua @@ -1,12 +1,24 @@ --- @class Render --- @field textures table +--- @field queue table[] local render = { - textures = {} + textures = {}, + queue = {}, + LAYERS = { + FLOOR = 1, + SHADOW = 2, + LIGHT = 3, + SPRITE = 4, + SPRITE_LIGHT = 5, + OVERLAY = 6 + } } function render:clear() local weather = Tree.level.weather local txs = self.textures + self.queue = {} + love.graphics.setCanvas(txs.shadowLayer) love.graphics.clear() love.graphics.setCanvas(txs.spriteLayer) @@ -21,6 +33,10 @@ function render:clear() love.graphics.clear() end +function render:enqueue(layer, z, func) + table.insert(self.queue, { layer = layer, z = z, func = func }) +end + function render:free() for _, tx in pairs(self.textures) do tx:release() @@ -57,19 +73,98 @@ function render:applyBlur(input, radius) return self.textures.tmp2 end +---@param params {w: number?, h: number?} +---@return table|Render +local function new(params) + local w = params.w or love.graphics.getWidth() + local h = params.h or love.graphics.getHeight() + local lowResScale = 0.5 + + return setmetatable({ + lowResScale = lowResScale, + queue = {}, + textures = { + shadowLayer = love.graphics.newCanvas(w * lowResScale, h * lowResScale), + spriteLayer = love.graphics.newCanvas(w, h), + spriteLightLayer = love.graphics.newCanvas(w * lowResScale, h * lowResScale), + floorLayer = love.graphics.newCanvas(w, h), + overlayLayer = love.graphics.newCanvas(w, h), + lightLayer = love.graphics.newCanvas(w * lowResScale, h * lowResScale), + tmp1 = love.graphics.newCanvas(w * lowResScale, h * lowResScale), + tmp2 = love.graphics.newCanvas(w * lowResScale, h * lowResScale), + } + }, { __index = render }) +end + function render:draw() local weather = Tree.level.weather local txs = self.textures - love.graphics.setCanvas(txs.lightLayer) - love.graphics.draw(self:applyBlur(txs.shadowLayer, 4 * Tree.level.camera.scale)) + + -- 1. Сортировка очереди + table.sort(self.queue, function(a, b) + if a.layer ~= b.layer then + return a.layer < b.layer + end + return a.z < b.z + end) + + -- 2. Рендеринг очереди в соответствующие Canvas + local currentLayer = nil + for _, entry in ipairs(self.queue) do + if entry.layer ~= currentLayer then + if currentLayer then + Tree.level.camera:detach() + local wasLowRes = currentLayer == self.LAYERS.SHADOW or currentLayer == self.LAYERS.SPRITE_LIGHT or + currentLayer == self.LAYERS.LIGHT + if wasLowRes then + love.graphics.pop() + end + end + currentLayer = entry.layer + local isLowRes = currentLayer == self.LAYERS.SHADOW or currentLayer == self.LAYERS.SPRITE_LIGHT or + currentLayer == self.LAYERS.LIGHT + + if currentLayer == self.LAYERS.FLOOR then + love.graphics.setCanvas(txs.floorLayer) + elseif currentLayer == self.LAYERS.SHADOW then + love.graphics.setCanvas(txs.shadowLayer) + elseif currentLayer == self.LAYERS.LIGHT then + love.graphics.setCanvas(txs.lightLayer) + elseif currentLayer == self.LAYERS.SPRITE then + love.graphics.setCanvas(txs.spriteLayer) + elseif currentLayer == self.LAYERS.SPRITE_LIGHT then + love.graphics.setCanvas(txs.spriteLightLayer) + elseif currentLayer == self.LAYERS.OVERLAY then + love.graphics.setCanvas(txs.overlayLayer) + end + + if isLowRes then + love.graphics.push() + love.graphics.scale(self.lowResScale) + end + Tree.level.camera:attach() + end + entry.func() + end + if currentLayer then + Tree.level.camera:detach() + local wasLowRes = currentLayer == self.LAYERS.SHADOW or currentLayer == self.LAYERS.SPRITE_LIGHT or + currentLayer == self.LAYERS.LIGHT + if wasLowRes then + love.graphics.pop() + end + end love.graphics.setCanvas() - -- self.lightLayer:newImageData():encode("png", "lightLayer.png") - -- os.exit(0) + -- 3. Пост-процессинг и композиция + love.graphics.setCanvas(txs.lightLayer) + -- Радиус блюра тоже масштабируем, так как текстура меньше + love.graphics.draw(self:applyBlur(txs.shadowLayer, 4 * Tree.level.camera.scale * self.lowResScale)) + love.graphics.setCanvas() local lightShader = Tree.assets.files.shaders.light_postprocess lightShader:send("scene", txs.floorLayer) - lightShader:send("light", self:applyBlur(txs.lightLayer, 2)) + lightShader:send("light", self:applyBlur(txs.lightLayer, 2 * self.lowResScale)) lightShader:send("ambient", { weather.ambientLight.x, weather.ambientLight.y, weather.ambientLight.z }) love.graphics.setShader(lightShader) love.graphics.draw(txs.floorLayer) @@ -84,23 +179,4 @@ function render:draw() love.graphics.setShader() end ----@param params {w: number?, h: number?} ----@return table|Render -local function new(params) - local w = params.w or love.graphics.getWidth() - local h = params.h or love.graphics.getHeight() - return setmetatable({ - textures = { - shadowLayer = love.graphics.newCanvas(w, h), - spriteLayer = love.graphics.newCanvas(w, h), - spriteLightLayer = love.graphics.newCanvas(w, h), - floorLayer = love.graphics.newCanvas(w, h), - overlayLayer = love.graphics.newCanvas(w, h), - lightLayer = love.graphics.newCanvas(w, h), - tmp1 = love.graphics.newCanvas(w, h), - tmp2 = love.graphics.newCanvas(w, h), - } - }, { __index = render }) -end - return { new = new } diff --git a/lib/spell/spell.lua b/lib/spell/spell.lua index 5ef80f0..e57ffba 100644 --- a/lib/spell/spell.lua +++ b/lib/spell/spell.lua @@ -54,33 +54,28 @@ function spell:draw() local path = self.path --[[@as Deque?]] if not path then return end --- Это отрисовка пути персонажа к мышке - Tree.level.camera:attach() - love.graphics.setCanvas(Tree.level.render.textures.overlayLayer) - local i = 0 - path:pop_front() - for p in path:values() do - i = i + 1 - local s = 1 / Tree.level.camera.pixelsPerMeter - local quad = i > self.distance and icons:pickQuad('dev_path_closed') or icons:pickQuad('dev_path') - love.graphics.draw(icons.atlas, quad, p.x, p.y, 0, s, s) - end - love.graphics.setCanvas() - Tree.level.camera:detach() - love.graphics.setColor(1, 1, 1) + Tree.level.render:enqueue(Tree.level.render.LAYERS.OVERLAY, 0, function() + local i = 0 + path:pop_front() + for p in path:values() do + i = i + 1 + local s = 1 / Tree.level.camera.pixelsPerMeter + local quad = i > self.distance and icons:pickQuad('dev_path_closed') or icons:pickQuad('dev_path') + love.graphics.draw(icons.atlas, quad, p.x, p.y, 0, s, s) + end + love.graphics.setColor(1, 1, 1) + end) else - Tree.level.camera:attach() - love.graphics.setCanvas(Tree.level.render.textures.overlayLayer) - love.graphics.setColor(1, 1, 1, 0.5) - for _, p in pairs(self.targets) do - local s = self.tSize / Tree.level.camera.pixelsPerMeter - local quad = icons:pickQuad('dev_target') - love.graphics.draw(icons.atlas, quad, p.x + 0.5 - self.tSize / 2, p.y + 0.5 - self.tSize / 2, 0, s, s) - end - love.graphics.setShader() - - love.graphics.setCanvas() - Tree.level.camera:detach() - love.graphics.setColor(1, 1, 1) + Tree.level.render:enqueue(Tree.level.render.LAYERS.OVERLAY, 0, function() + love.graphics.setColor(1, 1, 1, 0.5) + for _, p in pairs(self.targets) do + local s = self.tSize / Tree.level.camera.pixelsPerMeter + local quad = icons:pickQuad('dev_target') + love.graphics.draw(icons.atlas, quad, p.x + 0.5 - self.tSize / 2, p.y + 0.5 - self.tSize / 2, 0, s, s) + end + love.graphics.setShader() + love.graphics.setColor(1, 1, 1) + end) end end diff --git a/main.lua b/main.lua index f2e56ad..4369671 100644 --- a/main.lua +++ b/main.lua @@ -130,5 +130,5 @@ function love.resize(w, h) local render = Tree.level.render if not render then return end render:free() - Tree.level.render = (require "lib.level.render").new { w, h } + Tree.level.render = (require "lib.level.render").new { w = w, h = h } end -- 2.47.2 From 606c1158e33f1c5df96e150cb86ec74f5eee86de Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Thu, 23 Apr 2026 18:58:08 +0300 Subject: [PATCH 2/5] move character lighting to shader --- assets/shaders/sprite_light.glsl | 66 ++++++++++++++++++++++++ lib/character/behaviors/shadowcaster.lua | 26 ---------- lib/character/behaviors/sprite.lua | 42 ++++++++++++--- lib/level/render.lua | 23 +++------ main.lua | 8 +++ 5 files changed, 115 insertions(+), 50 deletions(-) create mode 100644 assets/shaders/sprite_light.glsl diff --git a/assets/shaders/sprite_light.glsl b/assets/shaders/sprite_light.glsl new file mode 100644 index 0000000..116e669 --- /dev/null +++ b/assets/shaders/sprite_light.glsl @@ -0,0 +1,66 @@ +#define MAX_LIGHTS 8 + +struct Light { + vec2 position; + vec3 color; +}; + +extern Light lights[MAX_LIGHTS]; +extern int num_lights; +extern vec2 sprite_pos; // Мировая позиция спрайта (в метрах) +extern vec3 ambient; // Эмбиентное освещение +extern vec3 sky; // Цвет неба + +// Функция для имитации easing.easeInSine +float easeInSine(float x) { + return 1.0 - cos((x * 3.14159) / 2.0); +} + +vec4 effect(vec4 vcolor, Image tex, vec2 texture_coords, vec2 screen_coords) +{ + vec3 baseLight = ambient + (vec3(1.0) - ambient) * sky; + 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); // 30% обесцвечивания, а то глаза выгорают + + vec4 texColor = Texel(tex, texture_coords); + if (texColor.a == 0.0) { + return vec4(0.0); + } + + vec3 pointLight = vec3(0.0); + + for (int i = 0; i < num_lights; i++) { + vec2 lightPos = lights[i].position; + vec2 lightVec = lightPos - sprite_pos; + float dist = length(lightVec); + + float attenuation = 0.0; + + // Логика из shadowcaster.lua: + // if lightPos.y > position.y then + // 1 - 0.3 * lightVec:length() + // elseif position.y - lightPos.y < 3 then + // (1 - easing.easeInSine((position.y - lightPos.y))) - 0.3 * lightVec:length() + + if (lightPos.y > sprite_pos.y) { + attenuation = 1.0 - 0.3 * dist; + } else { + float yDiff = sprite_pos.y - lightPos.y; + if (yDiff < 3.0) { + attenuation = (1.0 - easeInSine(yDiff)) - 0.3 * dist; + } + } + + attenuation = max(attenuation, 0.0); + pointLight += lights[i].color * attenuation; + } + + pointLight = clamp(pointLight, 0.0, 1.0); + vec3 a = clamp(ambient, 0.0, 1.0); + + // Канальный множитель: от ambient до 1 в зависимости от точечного света + // Это гарантирует, что спрайт не будет черным в отсутствие источников света + vec3 lightMultiplier = a + (vec3(1.0) - a) * pointLight; + + return vec4(texColor.rgb * (characterBaseLight + pointLight), texColor.a); +} diff --git a/lib/character/behaviors/shadowcaster.lua b/lib/character/behaviors/shadowcaster.lua index 904322e..0b2daeb 100644 --- a/lib/character/behaviors/shadowcaster.lua +++ b/lib/character/behaviors/shadowcaster.lua @@ -30,32 +30,6 @@ function behavior:draw() love.graphics.ellipse("fill", 0, 0, sprite.manifest.size / 2, sprite.manifest.size / 2 * math.cos(math.pi / 4)) love.graphics.pop() end) - - -- 2. Свет на спрайте - if #lights > 0 then - Tree.level.render:enqueue(Tree.level.render.LAYERS.SPRITE_LIGHT, position.y, function() - love.graphics.setBlendMode("add") - for _, light in ipairs(lights) do - local lightPos = light:has(Tree.behaviors.positioned).position - local lightVec = lightPos - position - - local lightColor = light:has(Tree.behaviors.light).color - if lightPos.y > position.y then - love.graphics.setColor(lightColor.x, lightColor.y, lightColor.z, - 1 - 0.3 * lightVec:length()) - elseif position.y - lightPos.y < 3 then - love.graphics.setColor(lightColor.x, lightColor.y, lightColor.z, - (1 - easing.easeInSine((position.y - lightPos.y))) - 0.3 * lightVec:length()) - end - - sprite.animationTable[sprite.state]:draw(sprite.sheets[sprite.state], - position.x, - position.y, nil, 1 / ppm * sprite.side, 1 / ppm, sprite.manifest.base.x, sprite.manifest.base.y) - end - love.graphics.setBlendMode("alpha") - love.graphics.setColor(1, 1, 1) - end) - end end return behavior diff --git a/lib/character/behaviors/sprite.lua b/lib/character/behaviors/sprite.lua index 6625b01..a3aa0ac 100644 --- a/lib/character/behaviors/sprite.lua +++ b/lib/character/behaviors/sprite.lua @@ -51,14 +51,42 @@ function sprite:draw() Tree.level.render:enqueue(Tree.level.render.LAYERS.SPRITE, position.y, function() love.graphics.setColor(1, 1, 1) - if Tree.level.selector.id == self.owner.id then - local texW, texH = self.sheets[self.state]:getWidth(), - self.sheets[self.state]:getHeight() - local shader = Tree.assets.files.shaders.outline - shader:send("texSize", { texW, texH }) - shader:send("time", love.timer:getTime()) - love.graphics.setShader(shader) + + -- Собираем источники света для шейдера + local lightIds = Tree.level.lightGrid:query(position, 5) + local lightsData = {} + local numLights = 0 + for _, id in ipairs(lightIds) do + local light = Tree.level.characters[id] + local lPos = light:has(Tree.behaviors.positioned).position + local lColor = light:has(Tree.behaviors.light).color + table.insert(lightsData, { lPos.x, lPos.y, lColor.x, lColor.y, lColor.z }) + numLights = numLights + 1 + if numLights >= 8 then break end end + + local lightShader = Tree.assets.files.shaders.sprite_light + local weather = Tree.level.weather + lightShader:send("sprite_pos", { position.x, position.y }) + 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("num_lights", numLights) + if numLights > 0 then + -- Формируем массив для шейдера: lights[i].position и lights[i].color + for i, data in ipairs(lightsData) do + lightShader:send(string.format("lights[%d].position", i - 1), { data[1], data[2] }) + lightShader:send(string.format("lights[%d].color", i - 1), { data[3], data[4], data[5] }) + end + end + + 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], position.x, position.y, nil, 1 / ppm * self.side, 1 / ppm, self.manifest.base.x, self.manifest.base.y) diff --git a/lib/level/render.lua b/lib/level/render.lua index ae23047..94a12ad 100644 --- a/lib/level/render.lua +++ b/lib/level/render.lua @@ -9,8 +9,7 @@ local render = { SHADOW = 2, LIGHT = 3, SPRITE = 4, - SPRITE_LIGHT = 5, - OVERLAY = 6 + OVERLAY = 5 } } @@ -23,8 +22,6 @@ function render:clear() love.graphics.clear() love.graphics.setCanvas(txs.spriteLayer) love.graphics.clear() - love.graphics.setCanvas(txs.spriteLightLayer) - love.graphics.clear(weather.skyLight.x, weather.skyLight.y, weather.skyLight.z) love.graphics.setCanvas(txs.floorLayer) love.graphics.clear() love.graphics.setCanvas(txs.lightLayer) @@ -86,7 +83,6 @@ local function new(params) textures = { shadowLayer = love.graphics.newCanvas(w * lowResScale, h * lowResScale), spriteLayer = love.graphics.newCanvas(w, h), - spriteLightLayer = love.graphics.newCanvas(w * lowResScale, h * lowResScale), floorLayer = love.graphics.newCanvas(w, h), overlayLayer = love.graphics.newCanvas(w, h), lightLayer = love.graphics.newCanvas(w * lowResScale, h * lowResScale), @@ -114,15 +110,13 @@ function render:draw() if entry.layer ~= currentLayer then if currentLayer then Tree.level.camera:detach() - local wasLowRes = currentLayer == self.LAYERS.SHADOW or currentLayer == self.LAYERS.SPRITE_LIGHT or - currentLayer == self.LAYERS.LIGHT + local wasLowRes = currentLayer == self.LAYERS.SHADOW or currentLayer == self.LAYERS.LIGHT if wasLowRes then love.graphics.pop() end end currentLayer = entry.layer - local isLowRes = currentLayer == self.LAYERS.SHADOW or currentLayer == self.LAYERS.SPRITE_LIGHT or - currentLayer == self.LAYERS.LIGHT + local isLowRes = currentLayer == self.LAYERS.SHADOW or currentLayer == self.LAYERS.LIGHT if currentLayer == self.LAYERS.FLOOR then love.graphics.setCanvas(txs.floorLayer) @@ -132,8 +126,6 @@ function render:draw() love.graphics.setCanvas(txs.lightLayer) elseif currentLayer == self.LAYERS.SPRITE then love.graphics.setCanvas(txs.spriteLayer) - elseif currentLayer == self.LAYERS.SPRITE_LIGHT then - love.graphics.setCanvas(txs.spriteLightLayer) elseif currentLayer == self.LAYERS.OVERLAY then love.graphics.setCanvas(txs.overlayLayer) end @@ -148,8 +140,7 @@ function render:draw() end if currentLayer then Tree.level.camera:detach() - local wasLowRes = currentLayer == self.LAYERS.SHADOW or currentLayer == self.LAYERS.SPRITE_LIGHT or - currentLayer == self.LAYERS.LIGHT + local wasLowRes = currentLayer == self.LAYERS.SHADOW or currentLayer == self.LAYERS.LIGHT if wasLowRes then love.graphics.pop() end @@ -171,12 +162,10 @@ function render:draw() love.graphics.setShader() love.graphics.draw(txs.overlayLayer) - love.graphics.setShader(lightShader) - lightShader:send("scene", txs.spriteLayer) - lightShader:send("light", txs.spriteLightLayer) + -- Спрайты уже полностью освещены в SpriteBehavior (с учетом ambient и point lights) + -- Поэтому рисуем их "как есть" love.graphics.draw(txs.spriteLayer) - love.graphics.setShader() end return { new = new } diff --git a/main.lua b/main.lua index 4369671..304959b 100644 --- a/main.lua +++ b/main.lua @@ -62,6 +62,14 @@ function love.load() Tree.level.turnOrder:add(id) end + --- Это тестовый источник света, привязанный к мышке, и я очень прошу его не трогать + character.spawn("light") + :addBehavior { + Tree.behaviors.positioned.new(), + Tree.behaviors.cursor.new(), + Tree.behaviors.light.new { color = Vec3 { 0.5, 0.5, 0.5 }, intensity = 5 } + } + Tree.level.turnOrder:endRound() print("Now playing:", Tree.level.turnOrder.current) -- 2.47.2 From 254e94fc295d230b30780f5f822e8a48f9896d73 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Thu, 23 Apr 2026 19:25:12 +0300 Subject: [PATCH 3/5] improve characters dynamic lighting --- assets/shaders/sprite_light.glsl | 46 +++++++++++++++-------------- lib/character/behaviors/sprite.lua | 47 +++++++++++++++++++++--------- main.lua | 14 ++++----- 3 files changed, 64 insertions(+), 43 deletions(-) diff --git a/assets/shaders/sprite_light.glsl b/assets/shaders/sprite_light.glsl index 116e669..1bc49d9 100644 --- a/assets/shaders/sprite_light.glsl +++ b/assets/shaders/sprite_light.glsl @@ -3,6 +3,7 @@ struct Light { vec2 position; vec3 color; + float radius; }; extern Light lights[MAX_LIGHTS]; @@ -18,9 +19,14 @@ float easeInSine(float x) { 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); // 30% обесцвечивания, а то глаза выгорают + vec3 characterBaseLight = mix(baseLight, vec3(luma), 0.3); vec4 texColor = Texel(tex, texture_coords); if (texColor.a == 0.0) { @@ -31,36 +37,32 @@ vec4 effect(vec4 vcolor, Image tex, vec2 texture_coords, vec2 screen_coords) for (int i = 0; i < num_lights; i++) { vec2 lightPos = lights[i].position; + float radius = lights[i].radius; vec2 lightVec = lightPos - sprite_pos; float dist = length(lightVec); - float attenuation = 0.0; + // Плавное затухание света по радиусу. + // Свет начинает гаснуть на 20% дистанции до края и полностью исчезает на границе радиуса. + // Это обеспечивает мягкий край света. + float radiusFalloff = smoothstep(radius, radius * 0.2, dist); - // Логика из shadowcaster.lua: - // if lightPos.y > position.y then - // 1 - 0.3 * lightVec:length() - // elseif position.y - lightPos.y < 3 then - // (1 - easing.easeInSine((position.y - lightPos.y))) - 0.3 * lightVec:length() + // Реализация псевдо-проекции (3D-эффект): + // Чтобы избежать резких скачков яркости при пересечении линии ног персонажа, + // мы используем плавный переход веса освещенности. + // Если источник света ниже персонажа (lightVec.y > 0), он считается "перед" ним. + // Если выше (lightVec.y < 0) — "за спиной". + // Мы плавно переходим от 0% до 100% мощности в диапазоне от -2.0 до 0.5 метров по вертикали. + float frontWeight = smoothstep(-2.0, 0.5, lightVec.y); - if (lightPos.y > sprite_pos.y) { - attenuation = 1.0 - 0.3 * dist; - } else { - float yDiff = sprite_pos.y - lightPos.y; - if (yDiff < 3.0) { - attenuation = (1.0 - easeInSine(yDiff)) - 0.3 * dist; - } - } - - attenuation = max(attenuation, 0.0); + float attenuation = radiusFalloff * frontWeight; pointLight += lights[i].color * attenuation; } + // Ограничиваем суммарную яркость точечных источников, чтобы избежать пересветов в белое pointLight = clamp(pointLight, 0.0, 1.0); - vec3 a = clamp(ambient, 0.0, 1.0); - - // Канальный множитель: от ambient до 1 в зависимости от точечного света - // Это гарантирует, что спрайт не будет черным в отсутствие источников света - vec3 lightMultiplier = a + (vec3(1.0) - a) * pointLight; + // Финальный расчет цвета: + // Мы берем текстуру персонажа и умножаем ее на сумму базового света мира и всех точечных источников. + // Это создает эффект, где персонаж одновременно "вписан" в атмосферу уровня и реагирует на динамические огни. return vec4(texColor.rgb * (characterBaseLight + pointLight), texColor.a); } diff --git a/lib/character/behaviors/sprite.lua b/lib/character/behaviors/sprite.lua index a3aa0ac..3dda50f 100644 --- a/lib/character/behaviors/sprite.lua +++ b/lib/character/behaviors/sprite.lua @@ -53,32 +53,51 @@ function sprite:draw() love.graphics.setColor(1, 1, 1) -- Собираем источники света для шейдера - local lightIds = Tree.level.lightGrid:query(position, 5) + local queryRadius = 12 -- Увеличенный радиус для плавности + local lightIds = Tree.level.lightGrid:query(position, queryRadius) local lightsData = {} - local numLights = 0 + for _, id in ipairs(lightIds) do - local light = Tree.level.characters[id] - local lPos = light:has(Tree.behaviors.positioned).position - local lColor = light:has(Tree.behaviors.light).color - table.insert(lightsData, { lPos.x, lPos.y, lColor.x, lColor.y, lColor.z }) - numLights = numLights + 1 - if numLights >= 8 then break end + local lightChar = Tree.level.characters[id] + local b = lightChar:has(Tree.behaviors.light) --[[@as LightBehavior]] + local lPos = lightChar:has(Tree.behaviors.positioned).position + local dist = (lPos - position):length() + + -- Берем только те, что могут дотянуться до нас своим радиусом + if dist < b.intensity + 2 then + table.insert(lightsData, { + x = lPos.x, + y = lPos.y, + r = b.color.x, + g = b.color.y, + b = b.color.z, + radius = b.intensity, + dist = dist + }) + end end + -- Сортируем по дистанции, чтобы выбрать 8 самых влиятельных + table.sort(lightsData, function(a, b) return a.dist < b.dist end) + local lightShader = Tree.assets.files.shaders.sprite_light local weather = Tree.level.weather + local numLights = math.min(#lightsData, 8) + lightShader:send("sprite_pos", { position.x, position.y }) 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("num_lights", numLights) - if numLights > 0 then - -- Формируем массив для шейдера: lights[i].position и lights[i].color - for i, data in ipairs(lightsData) do - lightShader:send(string.format("lights[%d].position", i - 1), { data[1], data[2] }) - lightShader:send(string.format("lights[%d].color", i - 1), { data[3], data[4], data[5] }) - end + + for i = 1, numLights do + local l = lightsData[i] + local idx = i - 1 + lightShader:send(string.format("lights[%d].position", idx), { l.x, l.y }) + lightShader:send(string.format("lights[%d].color", idx), { l.r, l.g, l.b }) + lightShader:send(string.format("lights[%d].radius", idx), l.radius) end + love.graphics.setShader(lightShader) if Tree.level.selector.id == self.owner.id then diff --git a/main.lua b/main.lua index 304959b..112fc59 100644 --- a/main.lua +++ b/main.lua @@ -62,13 +62,13 @@ function love.load() Tree.level.turnOrder:add(id) end - --- Это тестовый источник света, привязанный к мышке, и я очень прошу его не трогать - character.spawn("light") - :addBehavior { - Tree.behaviors.positioned.new(), - Tree.behaviors.cursor.new(), - Tree.behaviors.light.new { color = Vec3 { 0.5, 0.5, 0.5 }, intensity = 5 } - } + -- --- Это тестовый источник света, привязанный к мышке, и я очень прошу его не трогать + -- character.spawn("light") + -- :addBehavior { + -- Tree.behaviors.positioned.new(), + -- Tree.behaviors.cursor.new(), + -- Tree.behaviors.light.new { color = Vec3 { 0.5, 0.5, 0.5 }, intensity = 5 } + -- } Tree.level.turnOrder:endRound() -- 2.47.2 From cb53fa8d88ad01d40907e6abd0c0a98da2659082 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Thu, 23 Apr 2026 19:34:35 +0300 Subject: [PATCH 4/5] feat: implement sprite_light uber-shader with dynamic lighting and animated outline --- assets/shaders/sprite_light.glsl | 55 +++++++++++++++++++----------- lib/character/behaviors/sprite.lua | 14 ++++---- 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/assets/shaders/sprite_light.glsl b/assets/shaders/sprite_light.glsl index 1bc49d9..f55b5ae 100644 --- a/assets/shaders/sprite_light.glsl +++ b/assets/shaders/sprite_light.glsl @@ -12,6 +12,11 @@ extern vec2 sprite_pos; // Мировая позиция спрайта (в ме extern vec3 ambient; // Эмбиентное освещение extern vec3 sky; // Цвет неба +// Параметры выделения +extern bool is_selected; +extern vec2 tex_size; +extern float time; + // Функция для имитации easing.easeInSine float easeInSine(float x) { 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) { - // Расчет базового освещения мира: персонаж освещается смесью неба (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); + + // ЛОГИКА ОБВОДКИ (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) { 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); 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); // Плавное затухание света по радиусу. - // Свет начинает гаснуть на 20% дистанции до края и полностью исчезает на границе радиуса. - // Это обеспечивает мягкий край света. float radiusFalloff = smoothstep(radius, radius * 0.2, dist); // Реализация псевдо-проекции (3D-эффект): - // Чтобы избежать резких скачков яркости при пересечении линии ног персонажа, - // мы используем плавный переход веса освещенности. - // Если источник света ниже персонажа (lightVec.y > 0), он считается "перед" ним. - // Если выше (lightVec.y < 0) — "за спиной". - // Мы плавно переходим от 0% до 100% мощности в диапазоне от -2.0 до 0.5 метров по вертикали. float frontWeight = smoothstep(-2.0, 0.5, lightVec.y); float attenuation = radiusFalloff * frontWeight; pointLight += lights[i].color * attenuation; } - // Ограничиваем суммарную яркость точечных источников, чтобы избежать пересветов в белое pointLight = clamp(pointLight, 0.0, 1.0); // Финальный расчет цвета: - // Мы берем текстуру персонажа и умножаем ее на сумму базового света мира и всех точечных источников. - // Это создает эффект, где персонаж одновременно "вписан" в атмосферу уровня и реагирует на динамические огни. return vec4(texColor.rgb * (characterBaseLight + pointLight), texColor.a); } diff --git a/lib/character/behaviors/sprite.lua b/lib/character/behaviors/sprite.lua index 3dda50f..318f2ed 100644 --- a/lib/character/behaviors/sprite.lua +++ b/lib/character/behaviors/sprite.lua @@ -83,12 +83,20 @@ function sprite:draw() local lightShader = Tree.assets.files.shaders.sprite_light local weather = Tree.level.weather 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("sky", { weather.skyLight.x, weather.skyLight.y, weather.skyLight.z }) lightShader:send("ambient", { weather.ambientLight.x, weather.ambientLight.y, weather.ambientLight.z }) 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 local l = lightsData[i] local idx = i - 1 @@ -100,12 +108,6 @@ function sprite:draw() 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], position.x, position.y, nil, 1 / ppm * self.side, 1 / ppm, self.manifest.base.x, self.manifest.base.y) -- 2.47.2 From 3e23599c887ae1cbe40c169d396ca4e9fa9c4910 Mon Sep 17 00:00:00 2001 From: PeaAshMeter Date: Thu, 23 Apr 2026 19:35:44 +0300 Subject: [PATCH 5/5] add luals annotaions --- lib/level/render.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/level/render.lua b/lib/level/render.lua index 94a12ad..8cf636b 100644 --- a/lib/level/render.lua +++ b/lib/level/render.lua @@ -1,9 +1,12 @@ --- @class Render --- @field textures table --- @field queue table[] +--- @field lowResScale number +--- @field LAYERS table local render = { textures = {}, queue = {}, + lowResScale = 1.0, LAYERS = { FLOOR = 1, SHADOW = 2, -- 2.47.2