Back to Resources

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

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
				r, g, b = ENABLE_FOG and applyFog(Color3.fromRGB(r,g,b), distance) or r, g, b
			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
					r, g, b = ENABLE_FOG and applyFog(Color3new(r, g, b), distance):ToRGB() or r, g, b
				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)

Author: StarVSK

Posted: March 25, 2025