Literally Simulating Black Holes on Roblox

Today, I’m proud to announce a way to 2D render black holes real time!

Edit 3: I’ve made a major improvement on the rendering script, please find the reply below, or grab the NEWEST VERSION here:
Gravitational lensing 5.rbxl (70.1 KB)
The tutorial for this version is in the most recent reply I’ve made.

Instead of brute-forcing physics with thousands of parts (which I tried in a previous version, and it was painfully slow), this approach uses ray-per-pixel approximations of Einstein’s equations. The result is a much faster renderer that still gives that “Interstellar” style warped view of spacetime. Honestly, it’s a miracle that this can be done on this platform. For reference, Interstellar took 100 hours for each frame to render, but you can definitely argue that they used much higher resolution.

For the curious minded, I’ve provided a simple explanation of what you’re seeing in the demo before looking at it.

Details

Many of you may know black holes as this mysterious black sphere with a ring around it. But not all black holes have rings (called accretion disks) nor is shaped this way.


However, this video shows a fuller picture of what theoretical black holes look like.
https://www.youtube.com/watch?v=mst0BoDTQdo
To put it in tiktok terms, the no-hair theorem says that black holes are very simple things that are only be described by a few properties:

  1. Mass
  2. Spin
  3. Charge

The demo approximates Einstein’s General Relativity equations for all three properties to produce a 2D render using Roblox’s raycast.

Why approximate?


Yeah…

Now let’s see how it runs.
Demo

Roblox Simulation 1: Quick but pixelated accretion disk warping


You can see how it resembles closely to the black holes we know and love from Interstellar.

Roblox Simulation 2: Black Hole lenses background grid

Replicated in another software (Space Engine)

Roblox Simulation 3: Black Hole near NPC

You can see how awesome it can be.

The demos highlight the lensing at its most basic level, only mass is non zero. Drop to Features for the extensive highlights.

Features

As stated in the Details section if you haven’t read, the script can handle the angular momentum of objects too, showcased in this video.


As the angular momentum changes, spacetime tends to be dragged along, creating an oblate spheroid-like shape. The charge doesn’t change much, it’s still a sphere.

Comparison of a spinning mass vs not spinning.
No spin or charge:


Spinning:

You can see the event horizon of the black hole appears lopsided due to the angular momentum dragging light on one side more favorably, while light opposing the black hole falls into it.

Extra showcases

This image shows a torsion-like grid due to the black hole pulling on spacetime as it spins


Previous version

For those who are wondering how it’s different from the previous version, here’s how it worked before.

Every pixel would’ve casted physical parts out into the scene, then calculates the acceleration of the part using approximations of GR. Obviously the render time was extremely slow, but I thought it was groundbreaking for Roblox. This new version is a lot faster.

Sign off
I’ve made a post a day ago about this topic, however the program ran very slowly and uses brute force. Now I know this is a super niche thing on this platform but I hope this can be as fascinating to some of you as it is for me as I’ve been obsessed about space since forever. Using Roblox to do something as complicated as this is certainty not that heard of, although there’s one experience out there visualizing length contractions of spacetime in special relativity. But I’d love to see more people use Roblox as a silly way to create physics simulations in and not just stealing brainrots.

If anyone wants to possibly optimize or remix it:
Gravitational lensing 4.rbxl (69.4 KB)
I’m curious to see if it can be optimized more, since It’d be really cool to see high res simulations.

42 Likes

dawg what? aint no way we have mathematically accurate, realtime blackhole simulations in roblox before gta 6… yes, im still dragging ts. jokes aside though, how the actual flip did you do this?


ohhh ok yeah i understand… uh huh.

11 Likes

It’s honestly pretty cool what Roblox can do. I understand that it is a game engine, but it just surprises me when you can make cool stuff like this:


Roblox has so much potential as a game engine to be more like the other “big” game engines, they just need to give their developers more freedom.

Edit: Dude is your PC from NASA??? I can barely get 1 FPS :sob:

7 Likes

Haha thanks, I’d emphasize on the approximating part.

If you actually want to know the process behind the computation then here’s a gist of it:

This buddy right here is the “Mass tells spacetime how to curve, spacetime tells matter how to move” equation.


In the script, I didn’t actually use this equation verbatim because it’d take a really really long time to compute one pixel, let alone a frame. So i’d had to approximate this. Luckily, General relativity offers metric tensors like this one

for specific cases such as a singular mass with spin or charge. So we can skip computing the mess from the first image. Although both look like poopoo to render Realtime, this ones better (trust). However, we’re using one more trick, and that’s to dump the metric tensor and to approximate it mathematically instead.

What the script is really doing is casting rays and using forces to swiftly calculate the acceleration of each fake light particle. The important bit is that we’re not using any Newtonian gravitational equation, we’re using Einstein’s GR inspired equation to better approximate and get close to the real effects of GR by simply using forces without having to create a 4th dimension of time and geodesics nor newton’s law of gravitation.

Long story short, I had to derive a suitable equation moderately faithful to Einstein and use in the script and then compare it to other simulations like this


[Shows a spinning black hole]
And you can in fact see a similar lopsided shape of event horizons compared to mine and the other software’s. This is how I determined that i’ve done it right.

It’s definitely not intended to simulate the orbital mechanics, time dilation, or any other physical things. However, this script does a great job at visualizing the warping of spacetime around masses, similar to what you’d really see if put out there light years away.

5 Likes

Wow. Thanks for the explanation! That helped a bit (I’m not very smart), but dude that’s complicated. I would say you’re not much short of genius (or I could just be dumb).

3 Likes

Oh shoot I didn’t see that

I think it has to do with the settings in the script


Generally it’s best to keep steplength high and maxdistance low but it takes a bit of time to guess the right values for your scene without compromising the quality of the image, especially high res ones above 2500 pixels.

However, I’ve been working on a new way to render it even faster to remove the tedious part of tweaking the settings and it looks promising so far. I might make a reply or a new topic about it.

2 Likes

This is probably one of the most impressive roblox simulations I have EVER seen, do you intend on using this in a project?

1 Like

Initially, I created this mini project just for fun. But I do intend on using this for future space games if I do make it because the extreme sides of the universe (black holes, neutron stars, etc) has been poorly depicted on Roblox largely due to the platform’s limitations to appeal to mobile users and also misconceptions of what black holes really look like. If I do, I’ll probably start it late November (got exams) make a new topic about it since I think it’s possible to expand this mini project for multiplayer and 3D space, so it’s not like a 2D screen that we see at this stage.

But really overall goal is just to create resources for developers making space games as I did with my post from a year ago, solving problems such as the floating point error and making players stand on rough planets in all sorts of orientation just like real life.

2 Likes

Did you not learn general relativity in kindergarten

2 Likes

I couldn’t count to 3 in Kindergarten.

1 Like

I’m kidding, I was learning about general relativity and quantum mechanics by the time I was 5.

2 Likes

Yo quick update for the interested:

The new script produces more physically accurate and faster renders than the last one.
This image render took 1 second to render 200x200 (40,000 pixels) and looks super clean!

This previous version and for some people it didn’t run so well for quality such as this:

Here’s what the new ‘real time’ version should support with less of an FPS compromise as the old one 100x100 (10,000 pixels)


The script runs differently to significantly boost the actual FPS in studio, meaning the render might take a fraction of a second to render from right to left of the view instead of forcing an all-at-once rendering style.

But as you can tell the horizon looks off right now but will be fixed and released in the reply and this post soon. Super exciting imo!

4 Likes

WHAT!?
Dude casually drops a FORTY THOUSAND PIXEL 2-dimensional, mathematically accurate rendering of a BLACK HOLE. Would you mind giving a quick overview of HOW you did the optimizations :smiley:?

1 Like

I’ll explain it once it’s released this week, but it’s basically done!
Only have to write a full post about it but unfortunately outta time today :slight_smile:

However, I have a comparison though to see how much faster it is on my old laptop:
Both are 50x50 renders.

OLD

NEW


NEW 100x100 (10k pixels)


This 100x100 is just to demonstrate that it’s basically equivalently as fast as the old version, but you get 4x the resolution for the same duration (10k pixels vs 2.5k).

You can also see how much faster it is and also more accurate too!

I’m sorry, excuse me? This should not be that fast :sob:

Can’t wait!

Can’t wait for this either!

Let me know if you need some help with that. I would be more than happy to assist with this (relatively) simple task!

You could also ask ChatGPT to help you write the post. It would porbably be far better than you or I could do :shushing_face: :stuck_out_tongue:

Behold… the last update (for a while)

Gravitational lensing 5.rbxl (70.1 KB)


Demonstrations




Changes
  • Uses more accurate equations for spin and charge!
  • Way less studio lag… (If you want it to by the toggles)
  • The previous script supports spinning black holes but was dependent on arbitrary units of Roblox using AssemblyAngularVelocity. This new script uses the real spin parameter in General Relativity that ranges from 0-1. This value is dimensionless, so if you want to make it spin in a different axis, you gotta orientate the black hole itself.
How Does It Work???

The script creates a grid of pixel parts that act as a virtual screen, with each pixel tracing a ray from a camera origin into the scene. The script calculates how each ray would bend due to the black hole’s gravity using post-Newtonian and strong-deflection formulas, optionally including spin-induced frame-dragging. Rays that fall within the photon sphere form the black hole’s shadow, while others are deflected and raycast into the world to sample colors from visible objects.

How To Use It

Once in studio, the top of the script should appear like this:


These tunables are what you can change easily. I’d recommend to go with the settings in this screenshot for the best real time experience (already set in the file).

The important variable is `raysPerFrame’ to adjust for lag. The lower the number, the slower the computation and render, but the more FPS you gain in studio, visa versa. I found 9000 to be the best compromise for 50x50 renders. raysPerFrame is basically a limit for when each iteration of the main render loop, the script will compute the color for 9,000 pixels before yielding from right to left.
image

These three properties of the black hole can be changed in the BlackHole part.

  • Mass: how massive the black hole is. The bigger the number, the bigger the black hole, and the stronger the lensing gets.
  • Charge: an electric charge of the black hole. Essentially, whether the black hole carries a net positive or negative electric charge. The real life physical limit of this value is 1, ranging from 0-1. However, the script doesn’t limit larger numbers so go crazy with it!
  • SpinParam: How fast the black hole spins. The real life physical limit is also 0-1, and the script also doesn’t limit any other values, just plugs them into the equations. Go whacky! I think spins of 100 produces really trippy effects.

Hit run to go into freecam and easily move around objects.


And experiment! Move around stuff, add or delete objects, change the parameters of the black hole!

Caveats

A few things to consider while using the script:

  • The black hole will treat any foreground objects as a background
  • You can only have one black hole
  • Can start to be laggy above 2.5K pixels, but it really depends on your tolerance of what “real time” is. Personally, I’m fine with 10k pixels. it only takes a second for each render. But for 60fps, 2.5k is suitable.
  • Doesn’t render decals or textures, only parts (Parts, MeshParts, Unions)

This will probably be the last iteration of gravitational lensing for a month or so, but I might come back to make it a 3D render such that it can be implemented in games for players to see what black holes look like real time, at any angle, from their computer screens. This is more of a test of what we can do to make it as fast as possible, like an alpha stage of a resource.

Enjoy!

4 Likes

Wow!!! It’s finally here!!! This is so cool, dude. It was insane before, but this is just mind-blowing!

1 Like

Okay this is actually really good. I think this is one of the best things I have seen on roblox studio, GREAT J#B on the black hole

Pretty cool, I tried optimizing it and getting it working with editable images and got a solid 60ish fps

Code
--!native
--!optimize 2
--!strict

local Vector3new = Vector3.new
local Color3new = Color3.new
local Color3fromRGB = Color3.fromRGB

local Vector2new = Vector2.new
local mathfloor = math.floor
local mathsqrt = math.sqrt
local mathcos = math.cos
local mathsin = math.sin
local mathlog = math.log
local mathmax = math.max
local mathabs = math.abs
local bufferwriteu8 = buffer.writeu8
local buffercreate = buffer.create

local workspaceRaycast = workspace.Raycast

local AssetService = game:GetService("AssetService")

-- Constants
local SCREEN_WIDTH = 101
local SCREEN_HEIGHT = 101
local PIXEL_SIZE = 1.0
local MAX_DISTANCE = 5000
local G = 10
local C = 300.0
local C2 = 90000.0     -- C * C
local C3 = 27000000.0  -- C * C * C
local C4 = 8100000000.0 -- C * C * C * C
local USE_2PN = true
local SOFTENING = 1
local STRONG_SWITCH_FACTOR = 100
local WRAP_THRESHOLD = 2.827433388 -- math.pi * 0.9
local EPSILON = 1e-9
local EPSILON_SQ = 1e-18
local PI_15_4 = 11.78097245096 -- (15 * math.pi) / 4
local SCREEN_CENTER_X = 50.0 -- (SCREEN_WIDTH - 1) / 2
local SCREEN_CENTER_Y = 50.0 -- (SCREEN_HEIGHT - 1) / 2
local TOTAL_PIXELS = 10201 -- SCREEN_WIDTH * SCREEN_HEIGHT
local BUFFER_SIZE = 40804 -- TOTAL_PIXELS * 4

local ScreenPart = workspace:FindFirstChild("ScreenPart")
if not ScreenPart then
	ScreenPart = Instance.new("Part")
	ScreenPart.Name = "ScreenPart"
	ScreenPart.Size = Vector3new(SCREEN_WIDTH * PIXEL_SIZE, SCREEN_HEIGHT * PIXEL_SIZE, 0.1)
	ScreenPart.Position = Vector3new(0, 0, 0)
	ScreenPart.Anchored = true
	ScreenPart.CanCollide = false
	ScreenPart.Parent = workspace
end

local SurfaceGui = ScreenPart:FindFirstChild("ScreenGui")
if not SurfaceGui then
	SurfaceGui = Instance.new("SurfaceGui")
	SurfaceGui.Name = "ScreenGui"
	SurfaceGui.Face = Enum.NormalId.Front
	SurfaceGui.SizingMode = Enum.SurfaceGuiSizingMode.PixelsPerStud
	SurfaceGui.PixelsPerStud = 1 / PIXEL_SIZE
	SurfaceGui.Parent = ScreenPart
end

local ImageLabel = SurfaceGui:FindFirstChild("Screen")
if not ImageLabel then
	ImageLabel = Instance.new("ImageLabel")
	ImageLabel.Name = "Screen"
	ImageLabel.Size = UDim2.fromScale(1, 1)
	ImageLabel.BackgroundTransparency = 1
	ImageLabel.Parent = SurfaceGui
end

local EditableImage = AssetService:CreateEditableImage({
	Size = Vector2new(SCREEN_WIDTH, SCREEN_HEIGHT)
})
ImageLabel.ImageContent = Content.fromObject(EditableImage)

local OriginPart = workspace:FindFirstChild("Origin")
if not OriginPart then
	OriginPart = Instance.new("Part")
	OriginPart.Name = "Origin"
	OriginPart.Size = Vector3new(1, 1, 1)
	OriginPart.Transparency = 1
	OriginPart.Anchored = true
	OriginPart.CanCollide = false
	OriginPart.Parent = workspace
end
OriginPart.Position = Vector3new(0, 0, -1000)

-- Preallocate pixel data arrays
local PixelDirs = table.create(TOTAL_PIXELS)

-- Precompute pixel directions
local function ComputePixelDirs()
	local O = OriginPart.Position
	local OX, OY, OZ = O.X, O.Y, O.Z
	local ScreenPos = ScreenPart.Position
	local SPX, SPY, SPZ = ScreenPos.X, ScreenPos.Y, ScreenPos.Z
	local Epsilon = EPSILON
	local Index = 1
	local CenterX, CenterY = SCREEN_CENTER_X, SCREEN_CENTER_Y
	local PixelSize = PIXEL_SIZE

	for Y = 0, SCREEN_HEIGHT - 1 do
		for X = 0, SCREEN_WIDTH - 1 do
			local WorldX = SPX + (X - CenterX) * PixelSize
			local WorldY = SPY + (CenterY - Y) * PixelSize

			local VX = WorldX - OX
			local VY = WorldY - OY
			local VZ = SPZ - OZ

			local M = mathsqrt(VX * VX + VY * VY + VZ * VZ)
			if M > Epsilon then
				local InvM = 1 / M
				PixelDirs[Index] = Vector3new(VX * InvM, VY * InvM, VZ * InvM)
			else
				PixelDirs[Index] = Vector3new(0, 0, 1)
			end

			Index += 1
		end
	end
end

-- Find dominant BH
local function FindDominantBH()
	local SC = workspace:FindFirstChild("Scenario")
	if not SC then return nil end
	local Best, BestMass = nil, -math.huge
	for _, P in ipairs(SC:GetChildren()) do
		if P:IsA("BasePart") then
			local NV = P:FindFirstChild("MassV")
			local M = if NV and typeof(NV.Value) == "number" then NV.Value else P:GetMass()
			if M > BestMass then
				BestMass, Best = M, P
			end
		end
	end
	return Best
end

local BHPart = FindDominantBH()
if not BHPart then
	warn("No BH part found")
	return
end

local BH_Pos = BHPart.Position
local BH_PosX, BH_PosY, BH_PosZ = BH_Pos.X, BH_Pos.Y, BH_Pos.Z
local BH_GM, BH_GM_C2, BH_Bcrit, BH_BcritSq
local BH_Alpha1Coeff, BH_Alpha2Coeff, BH_AlphaQCoeff
local BH_Bswitch, BH_BswitchSq, BH_A, BH_B
local BH_SpinCoeff, BH_JaxisX, BH_JaxisY, BH_JaxisZ
local BH_HasCharge = false

local function BuildBH()
	BH_Pos = BHPart.Position
	BH_PosX, BH_PosY, BH_PosZ = BH_Pos.X, BH_Pos.Y, BH_Pos.Z

	local MassNV = BHPart:FindFirstChild("MassV")
	local M = if MassNV and typeof(MassNV.Value) == "number" then MassNV.Value else BHPart:GetMass()

	local ChargeNV = BHPart:FindFirstChild("Charge")
	local Q = if ChargeNV and typeof(ChargeNV.Value) == "number" then ChargeNV.Value else 0
	BH_HasCharge = mathabs(Q) > 0

	local SpinNV = BHPart:FindFirstChild("SpinParam")
	local SpinParam = if SpinNV and typeof(SpinNV.Value) == "number" then SpinNV.Value else 0

	-- Precomputed constants
	BH_GM = G * M
	BH_GM_C2 = BH_GM / C2
	BH_Bcrit = 3 * mathsqrt(3) * BH_GM_C2
	BH_BcritSq = BH_Bcrit * BH_Bcrit

	BH_Alpha1Coeff = 4 * BH_GM / C2
	BH_Alpha2Coeff = if USE_2PN then PI_15_4 * (BH_GM * BH_GM) / C4 else 0
	BH_AlphaQCoeff = Q * Q

	-- Strong-field matching
	local Bc = BH_Bcrit
	local Bswitch = mathmax(Bc * STRONG_SWITCH_FACTOR, Bc + EPSILON)
	local BswitchSq = Bswitch * Bswitch

	local Alpha1 = BH_Alpha1Coeff / (Bswitch + SOFTENING)
	local Alpha2 = if USE_2PN then BH_Alpha2Coeff / (BswitchSq + SOFTENING) else 0
	local AlphaQ = if BH_HasCharge then BH_AlphaQCoeff / (BswitchSq + SOFTENING) else 0
	local Alphaw = Alpha1 + Alpha2 + AlphaQ

	local Dalpha1 = -BH_Alpha1Coeff / BswitchSq
	local Dalpha2 = if USE_2PN then -2 * BH_Alpha2Coeff / (BswitchSq * Bswitch) else 0
	local DalphaQ = if BH_HasCharge then -2 * BH_AlphaQCoeff / (BswitchSq * Bswitch) else 0
	local Dalphaw = Dalpha1 + Dalpha2 + DalphaQ

	local A = -(Bswitch - Bc) * Dalphaw
	local Arg = mathmax(EPSILON, (Bswitch / Bc) - 1)
	local B = Alphaw + A * mathlog(Arg)

	BH_Bswitch = Bswitch
	BH_BswitchSq = BswitchSq
	BH_A = A
	BH_B = B

	-- Kerr spin
	local Jmag = SpinParam * (BH_GM * M) / C
	BH_SpinCoeff = if Jmag > 0 then (4 * G * Jmag) / C3 else 0

	local Axis = BHPart.CFrame.LookVector
	local AxisMag = mathsqrt(Axis.X * Axis.X + Axis.Y * Axis.Y + Axis.Z * Axis.Z)
	if AxisMag > EPSILON then
		local InvAxisMag = 1 / AxisMag
		BH_JaxisX = Axis.X * InvAxisMag
		BH_JaxisY = Axis.Y * InvAxisMag
		BH_JaxisZ = Axis.Z * InvAxisMag
	else
		BH_JaxisX, BH_JaxisY, BH_JaxisZ = 0, 1, 0
	end
end

BuildBH()
ComputePixelDirs()

-- Raycast params
local RPMain, RPOcc
local function MakeRPMain()
	local RP = RaycastParams.new()
	RP.FilterType = Enum.RaycastFilterType.Blacklist
	local Blacklist = {ScreenPart, OriginPart}
	local SC = workspace:FindFirstChild("Scenario")
	if SC then table.insert(Blacklist, SC) end
	RP.FilterDescendantsInstances = Blacklist
	return RP
end

local function MakeRPOcclusion()
	local RP = RaycastParams.new()
	RP.FilterType = Enum.RaycastFilterType.Blacklist
	RP.FilterDescendantsInstances = {ScreenPart, OriginPart}
	return RP
end

RPMain = MakeRPMain()
RPOcc = MakeRPOcclusion()

-- Optimized Rodrigues rotation (inlinede)
local function RotateVector(VX: number, VY: number, VZ: number, 
	AX: number, AY: number, AZ: number, Angle: number): (number, number, number)

	local CosA = mathcos(Angle)
	local SinA = mathsin(Angle)
	local Dot = AX * VX + AY * VY + AZ * VZ
	local CrossX = AY * VZ - AZ * VY
	local CrossY = AZ * VX - AX * VZ
	local CrossZ = AX * VY - AY * VX
	local OneMinusCos = 1 - CosA

	return VX * CosA + CrossX * SinA + AX * Dot * OneMinusCos, VY * CosA + CrossY * SinA + AY * Dot * OneMinusCos, VZ * CosA + CrossZ * SinA + AZ * Dot * OneMinusCos
end

-- Alpha calculation (inlined)
local function AlphaOfB(B: number): number
	if B <= BH_Bswitch then
		local Arg = mathmax(EPSILON, (B / BH_Bcrit) - 1)
		return -BH_A * mathlog(Arg) + BH_B
	else
		local BSq = B * B
		local Alpha1 = BH_Alpha1Coeff / (B + SOFTENING)
		local Alpha2 = if USE_2PN then BH_Alpha2Coeff / (BSq + SOFTENING) else 0
		local AlphaQ = if BH_HasCharge then BH_AlphaQCoeff / (BSq + SOFTENING) else 0
		return Alpha1 + Alpha2 + AlphaQ
	end
end

-- Extract color (inlined)
local function GetColor(Inst: Instance): (number, number, number)
	if Inst:IsA("BasePart") then
		local C = Inst.Color
		return C.R, C.G, C.B
	elseif Inst.Parent and Inst.Parent:IsA("BasePart") then
		local C = Inst.Parent.Color
		return C.R, C.G, C.B
	else
		return 1, 1, 1
	end
end

-- Main ray tracing
local function SampleRay(KdirX: number, KdirY: number, KdirZ: number, 
	O: Vector3, OX: number, OY: number, OZ: number): (number, number, number)

	-- Early setup
	local RelX = BH_PosX - OX
	local RelY = BH_PosY - OY
	local RelZ = BH_PosZ - OZ
	local Proj = RelX * KdirX + RelY * KdirY + RelZ * KdirZ

	if Proj > 0 then
		-- Closest approach point
		local ClosestX = OX + KdirX * Proj
		local ClosestY = OY + KdirY * Proj
		local ClosestZ = OZ + KdirZ * Proj

		local BvecX = BH_PosX - ClosestX
		local BvecY = BH_PosY - ClosestY
		local BvecZ = BH_PosZ - ClosestZ
		local BSq = BvecX * BvecX + BvecY * BvecY + BvecZ * BvecZ

		-- Shadow capture check
		if BSq <= BH_BcritSq then
			local Closest = Vector3new(ClosestX, ClosestY, ClosestZ)
			local Seg = Closest - O
			local RR = workspaceRaycast(workspace, O, Seg, RPOcc)
			if RR and RR.Instance then
				local HitPos = RR.Position
				local HitDistSq = (HitPos.X - OX) * (HitPos.X - OX) + 
					(HitPos.Y - OY) * (HitPos.Y - OY) + 
					(HitPos.Z - OZ) * (HitPos.Z - OZ)
				if HitDistSq < (Proj * Proj - 0.001) then
					return GetColor(RR.Instance)
				end
			end
			return 0, 0, 0
		end

		-- Deflection calculation
		local B = mathsqrt(BSq)
		if B < EPSILON then B = EPSILON end

		local InvB = 1 / B
		local BhatX = BvecX * InvB
		local BhatY = BvecY * InvB
		local BhatZ = BvecZ * InvB

		local Alpha = AlphaOfB(B)

		-- Axis = Kdir x Bhat
		local AxisX = KdirY * BhatZ - KdirZ * BhatY
		local AxisY = KdirZ * BhatX - KdirX * BhatZ
		local AxisZ = KdirX * BhatY - KdirY * BhatX
		local AxisMagSq = AxisX * AxisX + AxisY * AxisY + AxisZ * AxisZ

		local DirX, DirY, DirZ
		if AxisMagSq > EPSILON_SQ then
			local AxisMag = mathsqrt(AxisMagSq)
			local InvAxisMag = 1 / AxisMag
			DirX, DirY, DirZ = RotateVector(KdirX, KdirY, KdirZ,
				AxisX * InvAxisMag, AxisY * InvAxisMag, AxisZ * InvAxisMag, Alpha)
		else
			-- Fallback axis
			local AltX, AltY, AltZ
			if mathabs(KdirX) > 0.9 then
				AltX, AltY, AltZ = 0, 1, 0
			else
				AltX, AltY, AltZ = 1, 0, 0
			end
			local FBX = KdirY * AltZ - KdirZ * AltY
			local FBY = KdirZ * AltX - KdirX * AltZ
			local FBZ = KdirX * AltY - KdirY * AltX
			local FBMag = mathsqrt(FBX * FBX + FBY * FBY + FBZ * FBZ)
			local InvFBMag = 1 / FBMag
			DirX, DirY, DirZ = RotateVector(KdirX, KdirY, KdirZ,
				FBX * InvFBMag, FBY * InvFBMag, FBZ * InvFBMag, Alpha)
		end

		-- Spin deflection
		if BH_SpinCoeff > 0 then
			local SpinAxisX = BH_JaxisY * KdirZ - BH_JaxisZ * KdirY
			local SpinAxisY = BH_JaxisZ * KdirX - BH_JaxisX * KdirZ
			local SpinAxisZ = BH_JaxisX * KdirY - BH_JaxisY * KdirX
			local SpinMagSq = SpinAxisX * SpinAxisX + SpinAxisY * SpinAxisY + SpinAxisZ * SpinAxisZ

			if SpinMagSq > EPSILON_SQ then
				local SpinMag = mathsqrt(SpinMagSq)
				local InvSpinMag = 1 / SpinMag
				local SpinAngle = BH_SpinCoeff / (BSq + SOFTENING)
				DirX, DirY, DirZ = RotateVector(DirX, DirY, DirZ,
					SpinAxisX * InvSpinMag, SpinAxisY * InvSpinMag, SpinAxisZ * InvSpinMag, SpinAngle)
			end
		end

		-- Final raycast
		local Dir = Vector3new(DirX, DirY, DirZ)
		local RR = workspaceRaycast(workspace, O, Dir * MAX_DISTANCE, RPMain)
		if RR and RR.Instance then
			return GetColor(RR.Instance)
		else
			return 0, 0, 0
		end
	else
		-- No deflection
		local Kdir = Vector3new(KdirX, KdirY, KdirZ)
		local RR = workspaceRaycast(workspace, O, Kdir * MAX_DISTANCE, RPMain)
		if RR and RR.Instance then
			return GetColor(RR.Instance)
		else
			return 0, 0, 0
		end
	end
end

-- Render entire frame to buffer
local function RenderFrame(): buffer
	local PixelBuffer = buffercreate(BUFFER_SIZE)
	local O = OriginPart.Position
	local OX, OY, OZ = O.X, O.Y, O.Z

	local Offset = 0
	local Pixels = PixelDirs
	for I = 1, TOTAL_PIXELS do
		local Kdir = Pixels[I]
		local R, G, B = SampleRay(Kdir.X, Kdir.Y, Kdir.Z, O, OX, OY, OZ)

		bufferwriteu8(PixelBuffer, Offset, mathfloor(R * 255))
		bufferwriteu8(PixelBuffer, Offset + 1, mathfloor(G * 255))
		bufferwriteu8(PixelBuffer, Offset + 2, mathfloor(B * 255))
		bufferwriteu8(PixelBuffer, Offset + 3, 255)

		Offset += 4
	end

	return PixelBuffer
end

while task.wait() do
	local StartTime = os.clock()

	BuildBH()
	RPMain = MakeRPMain()
	RPOcc = MakeRPOcclusion()

	local PixelBuffer = RenderFrame()
	EditableImage:WritePixelsBuffer(Vector2new(0, 0), Vector2new(SCREEN_WIDTH, SCREEN_HEIGHT), PixelBuffer)

	local RenderTime = os.clock() - StartTime
end

7 Likes

Got 10fps with a 512x512 display using actors
AA.rbxl (77.1 KB)

1 Like