All resources
luaStarVSK · March 25, 2025

Editable Image Raycast Renderer

This script uses multiple advanced techniques to achieve a basic raycast renderer featuring shading, volumetrics, and more.

LocalScript - Goes inside StarterPlayerScripts

Block 1 of 1

local Players = game:GetService("Players")
local AssetService = game:GetService("AssetService")
local RunService = game:GetService("RunService")
local Lighting = game:GetService("Lighting")

local localPlayer = Players.LocalPlayer
local camera = workspace.CurrentCamera

-- Feature toggles
local ENABLE_LIGHTING = true          -- Toggle dynamic lighting calculations
local ENABLE_VOLUMETRIC_LIGHT = true  -- Toggle volumetric lighting effects
local ENABLE_SHADING = true           -- Toggle shading based on normal direction
local ENABLE_COLOR_QUANTIZATION = true -- Toggle retro color quantization
local ENABLE_CROSSHAIR = true         -- Toggle crosshair overlay
local ENABLE_FOG = false

-- Rendering constants
local RENDER_CONFIG = {
CANVAS_SIZE = Vector2.new(320, 240),
IMAGE_DEPTH = 300,
RAY_COUNT_HORIZONTAL = 92,
RAY_COUNT_VERTICAL = 92,
FOG_DENSITY = 0.01,
SKY_COLOR = Color3.new(0.1, 0.1, 0.2),
FLOOR_COLOR = Color3.new(0.2, 0.2, 0.2),
TARGET_COLOR = Color3.new(1, 0, 0),
SHADING_MIN = 0.2,
COLOR_QUANTIZATION_DEPTH = 40,
COLOR_QUANTIZATION_MIN = 4,
DISTANCE_PIXEL_RATIO = 50,
FLOOR_Y_POSITION = 0,
LIGHT_FALLOFF = 1.5,
AMBIENT_LIGHT = 0.15,
FULLBRIGHT_MIN = 0.6, -- Minimum brightness for unlit objects
}

-- Performance optimization
local floor, min, max, exp, clamp = math.floor, math.min, math.max, math.exp, math.clamp
local Vector3new, Color3new, Vector2new = Vector3.new, Color3.new, Vector2.new

-- Create the EditableImage once
local editableImage = AssetService:CreateEditableImage({Size = RENDER_CONFIG.CANVAS_SIZE})

-- Create GUI elements
local screenGui = Instance.new("ScreenGui")
screenGui.IgnoreGuiInset = true
screenGui.Parent = localPlayer:WaitForChild("PlayerGui")

local imageLabel = Instance.new("ImageLabel")
imageLabel.Size = UDim2.new(1, 0, 1, 0)
imageLabel.Position = UDim2.new(0.5, 0, 0.5, 0)
imageLabel.AnchorPoint = Vector2.new(0.5, 0.5)
imageLabel.BackgroundTransparency = 1
imageLabel.Parent = screenGui

-- Create raycast params once
local raycastParams = RaycastParams.new()
raycastParams.FilterType = Enum.RaycastFilterType.Blacklist

-- Light source cache
local lightSources = {}
local lastLightUpdateTime = 0
local LIGHT_UPDATE_INTERVAL = 1

-- Volumetric lighting constants
local VOLUME_SAMPLES = 3  -- Reduced for performance
local SCATTERING_COEFFICIENT = 0.02
local VOLUME_STEP_MULTIPLIER = 1/VOLUME_SAMPLES

-- Precalculate ray directions
local rayDirections = {}
for i = 0, RENDER_CONFIG.RAY_COUNT_HORIZONTAL - 1 do
rayDirections[i] = {}
for j = 0, RENDER_CONFIG.RAY_COUNT_VERTICAL - 1 do
    local rayX = (i / RENDER_CONFIG.RAY_COUNT_HORIZONTAL - 0.5) * 2
    local rayY = ((RENDER_CONFIG.RAY_COUNT_VERTICAL - j) / RENDER_CONFIG.RAY_COUNT_VERTICAL - 0.5) * 2
    rayDirections[i][j] = Vector3new(rayX, rayY, -1).Unit
end
end

-- Create pixel buffer
local pixelBufferSize = RENDER_CONFIG.CANVAS_SIZE.X * RENDER_CONFIG.CANVAS_SIZE.Y * 4
local pixelBuffer = buffer.create(pixelBufferSize)

-- Utility functions
local function calculateLightAttenuation(distance, range)
if distance >= range then return 0 end
local normalizedDist = distance / range
return (1 - normalizedDist^RENDER_CONFIG.LIGHT_FALLOFF)
end

local function updateLightSources()
lightSources = {}
for _, instance in ipairs(workspace:GetDescendants()) do
    if (instance:IsA("PointLight") or instance:IsA("SpotLight") or instance:IsA("SurfaceLight")) and instance.Enabled then
        local parent = instance.Parent
        if parent and parent:IsA("BasePart") then
            table.insert(lightSources, {
                position = parent.Position,
                color = instance.Color,
                range = instance.Range,
                brightness = instance.Brightness,
                type = instance.ClassName
            })
        end
    elseif instance:IsA("Part") and (instance.Material == Enum.Material.Neon or instance.Material == Enum.Material.CorrodedMetal) then
        table.insert(lightSources, {
            position = instance.Position,
            color = instance.Color,
            range = instance.Size.Magnitude * 2,
            brightness = 2,
            type = "NeonPart"
        })
    end
end
table.insert(lightSources, {
    direction = Lighting:GetSunDirection(),
    color = Lighting:GetMinutesAfterMidnight() / 60 > 6 and Lighting:GetMinutesAfterMidnight() / 60 < 18 and Lighting.OutdoorAmbient or Lighting.Ambient,
    brightness = Lighting:GetMinutesAfterMidnight() / 60 > 6 and Lighting:GetMinutesAfterMidnight() / 60 < 18 and 1 or 0.5,
    type = "DirectionalLight"
})
lastLightUpdateTime = tick()
end

-- Optimized volumetric lighting calculation
local function calculateVolumetricLight(rayOrigin, rayDir, rayLength)
if not ENABLE_VOLUMETRIC_LIGHT then return 0, 0, 0 end
local vR, vG, vB = 0, 0, 0
local stepSize = rayLength * VOLUME_STEP_MULTIPLIER

for _, light in ipairs(lightSources) do
    if light.type ~= "DirectionalLight" then
        local lightPos = light.position
        local lightRange = light.range
        local lightR = light.color.R * light.brightness
        local lightG = light.color.G * light.brightness
        local lightB = light.color.B * light.brightness

        for i = 1, VOLUME_SAMPLES do
            local sampleDistance = i * stepSize
            local sampleX = rayOrigin.X + rayDir.X * sampleDistance
            local sampleY = rayOrigin.Y + rayDir.Y * sampleDistance
            local sampleZ = rayOrigin.Z + rayDir.Z * sampleDistance

            local dirX = lightPos.X - sampleX
            local dirY = lightPos.Y - sampleY
            local dirZ = lightPos.Z - sampleZ
            local distToLight = (dirX*dirX + dirY*dirY + dirZ*dirZ)^0.5

            if distToLight < lightRange then
                local attenuation = calculateLightAttenuation(distToLight, lightRange)
                local scatteringFactor = exp(-sampleDistance * 0.01) * attenuation * SCATTERING_COEFFICIENT

                vR = vR + lightR * scatteringFactor
                vG = vG + lightG * scatteringFactor
                vB = vB + lightB * scatteringFactor
            end
        end
    end
end

return vR, vG, vB
end

-- Optimized lighting calculation
local function calculateLighting(position, normal, baseColor)
if not ENABLE_LIGHTING then
    return baseColor -- Return flat color if lighting is disabled
end
local totalR, totalG, totalB = RENDER_CONFIG.AMBIENT_LIGHT, RENDER_CONFIG.AMBIENT_LIGHT, RENDER_CONFIG.AMBIENT_LIGHT

for _, light in ipairs(lightSources) do
    if light.type == "DirectionalLight" then
        local nDotL = max(0, normal:Dot(-light.direction))
        totalR = totalR + light.color.R * nDotL * light.brightness
        totalG = totalG + light.color.G * nDotL * light.brightness
        totalB = totalB + light.color.B * nDotL * light.brightness
    else
        local lightDir = light.position - position
        local distToLight = lightDir.Magnitude
        if distToLight < light.range then
            local nDotL = max(0, normal:Dot(lightDir.Unit))
            local attenuation = calculateLightAttenuation(distToLight, light.range)
            local factor = nDotL * light.brightness * attenuation
            totalR = totalR + light.color.R * factor
            totalG = totalG + light.color.G * factor
            totalB = totalB + light.color.B * factor
        end
    end
end

-- Ensure minimum brightness for unlit objects
totalR = max(totalR, RENDER_CONFIG.FULLBRIGHT_MIN)
totalG = max(totalG, RENDER_CONFIG.FULLBRIGHT_MIN)
totalB = max(totalB, RENDER_CONFIG.FULLBRIGHT_MIN)

return Color3new(
    clamp(baseColor.R * totalR, 0, 1),
    clamp(baseColor.G * totalG, 0, 1),
    clamp(baseColor.B * totalB, 0, 1)
)
end

-- Simplified color operations
local function applyShading(color, shadeFactor)
if not ENABLE_SHADING then return color end
shadeFactor = max(shadeFactor, RENDER_CONFIG.FULLBRIGHT_MIN) -- Ensure minimum brightness
return Color3new(color.R * shadeFactor, color.G * shadeFactor, color.B * shadeFactor)
end

local function quantizeColor(color, depth)
if not ENABLE_COLOR_QUANTIZATION then return color end
local factor = max(RENDER_CONFIG.COLOR_QUANTIZATION_MIN, floor(depth / RENDER_CONFIG.COLOR_QUANTIZATION_DEPTH))
local invFactor = 1/factor
return Color3new(
    floor(color.R * factor) * invFactor,
    floor(color.G * factor) * invFactor,
    floor(color.B * factor) * invFactor
)
end

local function applyFog(color, distance)
if not ENABLE_FOG then return color end
local fogAmount = 1 - exp(-distance * RENDER_CONFIG.FOG_DENSITY)
return color:Lerp(RENDER_CONFIG.SKY_COLOR, fogAmount)
end

-- Optimized fill rectangle function
local function fillRect(startX, startY, endX, endY, r, g, b)
r, g, b = floor(r * 255), floor(g * 255), floor(b * 255)
for y = startY, endY do
    local rowStart = y * RENDER_CONFIG.CANVAS_SIZE.X * 4
    for x = startX, endX do
        local index = rowStart + x * 4
        buffer.writeu8(pixelBuffer, index, r)
        buffer.writeu8(pixelBuffer, index + 1, g)
        buffer.writeu8(pixelBuffer, index + 2, b)
        buffer.writeu8(pixelBuffer, index + 3, 255)  -- Alpha
    end
end
end

local pixelsPerRayX = RENDER_CONFIG.CANVAS_SIZE.X / RENDER_CONFIG.RAY_COUNT_HORIZONTAL
local pixelsPerRayY = RENDER_CONFIG.CANVAS_SIZE.Y / RENDER_CONFIG.RAY_COUNT_VERTICAL

local function castRayAndRender()
if tick() - lastLightUpdateTime > LIGHT_UPDATE_INTERVAL then
    updateLightSources()
end

raycastParams.FilterDescendantsInstances = {localPlayer.Character}

local cameraPos = camera.CFrame.Position
local cameraMatrix = camera.CFrame

for i = 0, RENDER_CONFIG.RAY_COUNT_HORIZONTAL - 1 do
    for j = 0, RENDER_CONFIG.RAY_COUNT_VERTICAL - 1 do
        local direction = cameraMatrix:VectorToWorldSpace(rayDirections[i][j])
        local raycastResult = workspace:Raycast(cameraPos, direction * RENDER_CONFIG.IMAGE_DEPTH, raycastParams)

        local r, g, b, distance
        if raycastResult then
            local hitPart = raycastResult.Instance
            local hitPos = raycastResult.Position
            local hitNormal = raycastResult.Normal
            distance = (hitPos - cameraPos).Magnitude

            local baseColor = hitPart.Color
            local litColor = ENABLE_LIGHTING and calculateLighting(hitPos, hitNormal, baseColor) or baseColor
            local shadeFactor = ENABLE_SHADING and max(RENDER_CONFIG.SHADING_MIN, hitNormal:Dot(-direction)) or 1
            local shadedColor = applyShading(litColor, shadeFactor)
            local quantizedColor = quantizeColor(shadedColor, distance)

            -- Add volumetric lighting if enabled, else use base color
            local vR, vG, vB = calculateVolumetricLight(cameraPos, direction, distance)
            r = ENABLE_VOLUMETRIC_LIGHT and clamp(quantizedColor.R + vR, 0, 1) or quantizedColor.R
            g = ENABLE_VOLUMETRIC_LIGHT and clamp(quantizedColor.G + vG, 0, 1) or quantizedColor.G
            b = ENABLE_VOLUMETRIC_LIGHT and clamp(quantizedColor.B + vB, 0, 1) or quantizedColor.B

            -- Apply fog if enabled (Color3 components are 0–1 here)
            if ENABLE_FOG then
                local c = applyFog(Color3new(r, g, b), distance)
                r, g, b = c.R, c.G, c.B
            end
        else
            local floorHitDistance = (RENDER_CONFIG.FLOOR_Y_POSITION - cameraPos.Y) / direction.Y
            if floorHitDistance > 0 and floorHitDistance < RENDER_CONFIG.IMAGE_DEPTH then
                distance = floorHitDistance
                local floorHitPos = cameraPos + direction * floorHitDistance
                local floorNormal = Vector3new(0, 1, 0)

                local baseColor = RENDER_CONFIG.FLOOR_COLOR
                local litFloorColor = ENABLE_LIGHTING and calculateLighting(floorHitPos, floorNormal, baseColor) or baseColor
                local shadeFactor = ENABLE_SHADING and max(RENDER_CONFIG.SHADING_MIN, floorNormal:Dot(-direction)) or 1
                local shadedFloorColor = applyShading(litFloorColor, shadeFactor)
                local quantizedColor = quantizeColor(shadedFloorColor, distance)

                -- Add volumetric lighting for floor if enabled
                local vR, vG, vB = calculateVolumetricLight(cameraPos, direction, distance)
                r = ENABLE_VOLUMETRIC_LIGHT and clamp(quantizedColor.R + vR, 0, 1) or quantizedColor.R
                g = ENABLE_VOLUMETRIC_LIGHT and clamp(quantizedColor.G + vG, 0, 1) or quantizedColor.G
                b = ENABLE_VOLUMETRIC_LIGHT and clamp(quantizedColor.B + vB, 0, 1) or quantizedColor.B

                -- Apply fog if enabled
                if ENABLE_FOG then
                    local c = applyFog(Color3new(r, g, b), distance)
                    r, g, b = c.R, c.G, c.B
                end
            else
                -- For sky, use base sky color with optional volumetric lighting
                local vR, vG, vB = calculateVolumetricLight(cameraPos, direction, RENDER_CONFIG.IMAGE_DEPTH)
                r = ENABLE_VOLUMETRIC_LIGHT and clamp(RENDER_CONFIG.SKY_COLOR.R + vR, 0, 1) or RENDER_CONFIG.SKY_COLOR.R
                g = ENABLE_VOLUMETRIC_LIGHT and clamp(RENDER_CONFIG.SKY_COLOR.G + vG, 0, 1) or RENDER_CONFIG.SKY_COLOR.G
                b = ENABLE_VOLUMETRIC_LIGHT and clamp(RENDER_CONFIG.SKY_COLOR.B + vB, 0, 1) or RENDER_CONFIG.SKY_COLOR.B
                distance = RENDER_CONFIG.IMAGE_DEPTH
            end
        end

        local startX = floor(i * pixelsPerRayX)
        local endX = floor((i + 1) * pixelsPerRayX) - 1
        local startY = floor(j * pixelsPerRayY)
        local endY = floor((j + 1) * pixelsPerRayY) - 1

        fillRect(startX, startY, endX, endY, r, g, b)
    end
end

editableImage:WritePixelsBuffer(Vector2new(0, 0), RENDER_CONFIG.CANVAS_SIZE, pixelBuffer)

-- Draw crosshair if enabled
if ENABLE_CROSSHAIR then
    local center = Vector2new(RENDER_CONFIG.CANVAS_SIZE.X/2, RENDER_CONFIG.CANVAS_SIZE.Y/2)
    editableImage:DrawLine(Vector2new(center.X - 10, center.Y), Vector2new(center.X + 10, center.Y), RENDER_CONFIG.TARGET_COLOR, 0, Enum.ImageCombineType.Overwrite)
    editableImage:DrawLine(Vector2new(center.X, center.Y - 10), Vector2new(center.X, center.Y + 10), RENDER_CONFIG.TARGET_COLOR, 0, Enum.ImageCombineType.Overwrite)
end

imageLabel.ImageContent = Content.fromObject(editableImage)
end

updateLightSources()
RunService.RenderStepped:Connect(castRayAndRender)
editable-image-raycast-renderer · lua · 341 lines

Copy all

Concatenates every block with a title comment — roughly 14 KB of source.