Implementation of optical scope shadow, effect of scope parallax adjustment, and parallax-free reflex sight

There is a Russian translation of this tutorial available.

This tutorial will show you a simple way to implement a scope shadow effect that you can often see in modern FPS games.


The method that is shown in this tutorial also allows you to create parallax-free reflex sights (with the reticle remaining on the target even though the eye is off the optical axis) and an effect of parallax adjustment of the scope.

Scope shadow texture

This method uses a texture (Texture object) stretched over a plane surface of the cylinder.
Decal can’t be used here because it can’t be offset, unlike texture.
SurfaceGui can’t be used either because it doesn’t use a UV map of the mesh, and UI clipping currently (14.12.2021) doesn’t work correctly with UICorner.

The texture used here is a black canvas with a round opening in the center (transparent circle) with a width and height equal to half the width and height of the canvas, respectively.
Since, unlike the decal, the texture is repeated, the black canvas on the sides by 25% of the canvas width is enough to completely cover the scope.
This gives us the highest possible opening resolution, which is important to prevent noticeable pixelation when your game is played on a high resolution.
For the same reason, I recommend using the maximum image resolution available on Roblox - 1024 by 1024.

As the result, this texture should look like this

If you want a simple way to create a scope parallax adjustment effect, then combine reticle texture with this texture.
This texture is stretched over a plane surface of the cylinder. In my case, the “Face” property of the texture object is equal to Left.
It will be later scaled and positioned correctly using code.

The algorithm

On the picture above using my mad drawing skillz I tried to show how an offset is calculated:

  1. The positions of the bases of the cylinder (they play roles of the objective lens and the ocular lens) relative to the camera are determined.
  2. Distance from the objective lens (the farthest one from the stock) to the camera plane is equated to the distance between the ocular lens and the camera plane, with the tangent being kept the same. It is being done twice on different planes, so both X(U) and Y(V) offsets of the texture relative to the camera are obtained.
  3. From these offsets, we subtract the position of the ocular lens relative to the camera on X and Y axes to obtain an offset of the texture relative to the ocular lens (which is a left face of the cylinder on which the texture is placed).

This code should run before every frame (RunService.RenderStepped).

local Scope = workspace.Scope --Cylinder
local Texture = Scope.ScopeTexture

local CameraCF = workspace.CurrentCamera.CFrame

--STEP 1
--Calculate absolute CFrames of the lenses
local ScopeViewEndCF = Scope.CFrame * CFrame.new(-Scope.Size.X/2,0,0)
local ScopeFarEndCF = Scope.CFrame * CFrame.new(Scope.Size.X/2,0,0)

--Caclulate CFrames of the lenses relative to the camera
local ViewToCam = CameraCF:ToObjectSpace(ScopeViewEndCF) --ocular lens
local FarEndToCam = CameraCF:ToObjectSpace(ScopeFarEndCF) --objective lens

--STEP 2
--Save the distance between the ocular lense and the camera plane.
--It's fine if it's negative, since in Roblox -Z is the "Forward" direction.
local DistFromClipPlane = ViewToCam.Z

--Calculate the offset of the texture relative to the camera
local U_OFFSET_FROM_CAMERA = FarEndToCam.X/FarEndToCam.Z * DistFromClipPlane
local V_OFFSET_FROM_CAMERA = FarEndToCam.Y/FarEndToCam.Z * DistFromClipPlane

--STEP 3
--Calculate the offset of the texture offset relative to the ocular lense.
--The result is already in studs, which is what we need.
local U_OFFSET = U_OFFSET_FROM_CAMERA - ViewToCam.X
local V_OFFSET = V_OFFSET_FROM_CAMERA - ViewToCam.Y

Further, as texture size and its offset is measured in studs, they depend on the size of the cylinder.
We need to make sure that the size of the cylinder correctly relates to the size of the texture.
Our texture should be twice as big as the cylinder.

--Calculate the size of the texture
local CylinderSize = math.min(Scope.Size.Y, Scope.Size.Z)
local TextureStuds = CylinderSize * 2
	
Texture.StudsPerTileU = TextureStuds
Texture.StudsPerTileV = TextureStuds

Since the texture is anchored to the corner of the face of the mesh, we need to center it first:

local CenterOffset = (TextureStuds-CylinderSize)/2

Next, we could set its offset:

Texture.OffsetStudsU = CenterOffset - U_OFFSET
Texture.OffsetStudsV = CenterOffset + V_OFFSET

But the texture repeats, so we need to limit its offset first.
So, instead of these 2 lines we write this:

--Calculate the max offset from the center
local MaxOffsetFromCenter = TextureStuds/2

--Caclulate max texture offsets
local MaxOffset = CenterOffset + MaxOffsetFromCenter
local MinOffset = CenterOffset - MaxOffsetFromCenter

Texture.OffsetStudsU = math.clamp(CenterOffset - U_OFFSET, MinOffset, MaxOffset)
Texture.OffsetStudsV = math.clamp(CenterOffset + V_OFFSET, MinOffset, MaxOffset)

However, we still have one more problem: the texture offset that we calculated is relative to the position of the face of the mesh, so if the scope is rotated around its optical axis, the texture will move in the wrong direction.

So we replace the last 2 lines with the following code:

--Calculate the CFrame of the texture without accouting for the rotaiton of the scope around its optical axis.
local OffsetCFrame = CFrame.lookAt(ScopeViewEndCF.Position, ScopeViewEndCF.Position+ScopeViewEndCF.RightVector) * CFrame.new(Vector3.new(U_OFFSET, V_OFFSET, 0))
--Caclulate the CFrame of the texture relative to the ocular lens
local OffsetCFrameLocalized = ScopeViewEndCF:ToObjectSpace(OffsetCFrame)

Texture.OffsetStudsU = math.clamp(CenterOffset - OffsetCFrameLocalized.Z, MinOffset, MaxOffset)
Texture.OffsetStudsV = math.clamp(CenterOffset + OffsetCFrameLocalized.Y, MinOffset, MaxOffset)

At this point the basic implementation of the effect is done, so you can wrap it into a module that tracks scopes in Workspace using CollectionService, or you can add support for multiple independent textures on a single scope, so scope parallax can be adjusted during the game. This simple algorithm can be a foundation for quite a lot of cool stuff.

Usage with SurfaceGui

In the future, Roblox developers may fix clipping for UI with UICorner, and you may want to replace the texture with SurfaceGui.
For centering of the ImageLabel it’s easier to use the AnchorPoint property and the Scale component of UDim2, so all we need is to convert offset from studs to pixels.
To do that simply multiply the offset by the “PixelsPerStud” property of SurfaceGui. Also, you’ll need to invert offset on both X and Y axes.
The code below does it all:

local pps = SurfaceGui.PixelsPerStud
-- Correct if AnchorPoint is at 0.5, 0.5
SurfaceGui.Frame.Position = UDim2.new(0.5, OffsetCFrameLocalized.Z*pps, 0.5, -OffsetCFrameLocalized.Y*pps)

Reflex sight

Effect already works the way we need it to, we just need to replace the texture of the scope with the texture of the reticle of the reflex sight (Keep the scale in mind, the texture will be twice as big as the sight).
Also, we’ll need a “long” scope for reflex sight, preferably the objective lens should be 1000+ studs away from the ocular lens.
To keep mesh size small we’ll set the position of the objective lens using some code:

local ScopeFarEndCF = ViewToCam * CFrame.new(1000,0,0)

If the shape of your reflex sight is close to a rectangle (or clipping finally works with rounded corners), then you can try to replace the texture with SurfaceGui as written above. This will allow you to give the reticle a glow effect by setting LightInfluence of the SurfaceGui to 0 and increasing its brightness.
This will also allow the reticle to be perfectly visible in the dark.

Full code and demo of the effect (rbxl file)

In this rbxl file, you’ll find fully working code and a scope demo.
For ease of use, the module finds scopes in Workspace using CollectionService.

You can use code from these files in your project without mentioning the author (although it would be cool if you do).
ParallaxDemo.rbxl (34.9 KB)
ParallaxDemoReflex.rbxl (37.3 KB)

88 Likes

Hello! I’m super new to scripting and I find this very interesting. I just have one question though, how do you make it so I can put the scope into a tool? Or even just make it so the scope doesn’t follow the players camera once you spawn and you can just put it into workspace?

1 Like

In my demo rbxl file this behaviour is implemented in StarterPlayer > StarterPlayerScripts > LocalScript.
To load scope module all code you need is

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ScopeParallax = require(ReplicatedStorage.ScopeParallax)
ScopeParallax.SetEnabled(true)

, the rest can be removed.
Scope can be any cylinder part with a tag “Scope” (you can add it using a command line or some plugin) and a texture called “ScopeTexture”, it also must be on the left face of the cylinder. You can just copy it from ReplicatedStorage.

3 Likes

yo dude it doesn’t work with canvas group pls fix it

So, I have a problem which when I attempts to scale the scope down manually to a certain point, the texture scale is messed up. Is there a way to fix it

Ok nvm, I figured this out, (btw this is 9 days later), so I basically go back up and check for any of the part of this tutorial that I missed which then I came across this

--Calculate the size of the texture
local CylinderSize = math.min(Scope.Size.Y, Scope.Size.Z)
local TextureStuds = CylinderSize * 2 -- I found that in order for the Texture to fit properly (size) on the Cylinder, I have to use the provided equation
	
Texture.StudsPerTileU = TextureStuds
Texture.StudsPerTileV = TextureStuds

local CenterOffset = (TextureStuds-CylinderSize)/2 -- knowing in order to set center offset (position) for both the the Scope and Grid, use this equation provided

Then I used this two lines to test it out in the workspace (by just basically calculate it by hand… using a calculator), but realized it only centers the Scope (the shadow part) , while the Grid (crosshair) is not centered that causes the problem which both of them off position.

image
image
image

--Just a test feature, I didn't put this in the actual code
Texture.OffsetStudsU = CenterOffset 
Texture.OffsetStudsV = CenterOffset 

So in order for the Grid (crosshair) texture to be centered, I applied the same thing, using the same concept for the Scope (shadow part)

--This sets the proper scale for the Grip (crosshair)
Grid.OffsetStudsU = CenterOffset 
Grid.OffsetStudsV = CenterOffset 

Or you could do the math by hand or just simply copy and paste the values of the Scope (Shadow) to the Grid (crosshair)
→ so a result they should both look the same
image
image
Now everything is fixed with proper scale and position (and you’ve scaled it down to a smaller size)

And for the actual code I decided to use this instead

--Set up the Grid (crosshair) inside a script, using same concept as the Scope (Shadow part)
Grid.StudsPerTileU = TextureStuds
Grid.StudsPerTileV = TextureStuds

Grid.OffsetStudsU = math.clamp(CenterOffset - OffsetCFrameLocalized.Z, MinOffset, MaxOffset)
Grid.OffsetStudsV = math.clamp(CenterOffset + OffsetCFrameLocalized.Y, MinOffset, MaxOffset)

And here’s the full improvised version: (you can just select all, delete everything inside the module script and paste this in) Credit to @TheArturZh

--!nonstrict
--Module is written by TheArturZh

local RunService = game:GetService("RunService")
local CollectionService = game:GetService("CollectionService")

local Camera = workspace.CurrentCamera

local function UpdateScope(Scope, Texture, Grid)
	local CameraCF = Camera.CFrame

	local ScopeViewEndCF = Scope.CFrame * CFrame.new(-Scope.Size.X/2,0,0)
	local ScopeFarEndCF = Scope.CFrame * CFrame.new(Scope.Size.X/2,0,0)

	local ViewToCam = CameraCF:ToObjectSpace(ScopeViewEndCF)
	local FarEndToCam = CameraCF:ToObjectSpace(ScopeFarEndCF)

	local DistFromClipPlane = ViewToCam.Z
	local CylinderSize = math.min(Scope.Size.Y, Scope.Size.Z)

	local TextureStuds = CylinderSize * 2
	local CenterOffset = (TextureStuds-CylinderSize)/2

	Texture.StudsPerTileU = TextureStuds
	Texture.StudsPerTileV = TextureStuds
	if Grid then
		Grid.StudsPerTileU = TextureStuds
		Grid.StudsPerTileV = TextureStuds
	end

	local MaxOffsetFromCenter = TextureStuds/2
	local MaxOffset = CenterOffset + MaxOffsetFromCenter
	local MinOffset = CenterOffset - MaxOffsetFromCenter

	local U_OFFSET = FarEndToCam.X/FarEndToCam.Z * DistFromClipPlane - ViewToCam.X
	local V_OFFSET = FarEndToCam.Y/FarEndToCam.Z * DistFromClipPlane - ViewToCam.Y

	local OffsetCFrame = CFrame.lookAt(ScopeViewEndCF.Position, ScopeViewEndCF.Position+ScopeViewEndCF.RightVector) * CFrame.new(Vector3.new(U_OFFSET, V_OFFSET, 0))
	local OffsetCFrameLocalized = ScopeViewEndCF:ToObjectSpace(OffsetCFrame)

	Texture.OffsetStudsU = math.clamp(CenterOffset - OffsetCFrameLocalized.Z, MinOffset, MaxOffset)
	Texture.OffsetStudsV = math.clamp(CenterOffset + OffsetCFrameLocalized.Y, MinOffset, MaxOffset)
	if Grid then
		Grid.OffsetStudsU = math.clamp(CenterOffset - OffsetCFrameLocalized.Z, MinOffset, MaxOffset)
		Grid.OffsetStudsV = math.clamp(CenterOffset + OffsetCFrameLocalized.Y, MinOffset, MaxOffset)
	end
end

local function UpdateAllScopes()
	for _, scope in pairs(CollectionService:GetTagged("Scope")) do
		local texture = scope:FindFirstChild("ScopeTexture")
		local grid = scope:FindFirstChild("Grid")
		if texture then
			UpdateScope(scope, texture, grid)
		end
	end
end

local module = {}

local Connection

module.SetEnabled = function(Enabled: boolean)
	if Enabled then
		if not Connection then
			Connection = RunService.RenderStepped:Connect(UpdateAllScopes)
		end
	elseif Connection then
		Connection:Disconnect()
		Connection = nil
	end
end

return module

In conclusion, I need to play around more with the value and see how it affects stuff, and read and observe the information more careful next time

4 Likes

Thank you so much for this! I’ve finally managed to recreate a realistic scope system more akin of Squad while replicating the lesser known “focal plane” too!
video

3 Likes

How did you make scope shadow seem more aggressive, any changes to the script?

I did it by just multiplying the Grid OffsetCFrameLocalized by a high amount!

Texture.OffsetStudsU = math.clamp(CenterOffset - OffsetCFrameLocalized.Z, MinOffset, MaxOffset)
	Texture.OffsetStudsV = math.clamp(CenterOffset + OffsetCFrameLocalized.Y, MinOffset, MaxOffset)
	
	if Grid then
		Grid.OffsetStudsU = math.clamp(CenterOffset - OffsetCFrameLocalized.Z * 8, MinOffset, MaxOffset)
		Grid.OffsetStudsV = math.clamp(CenterOffset + OffsetCFrameLocalized.Y * 8, MinOffset, MaxOffset)
	end
3 Likes

Thank you, you’re a life saver! :smiley:

how’d u even get it to work with your gun :sob: I’m having trouble w that.