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.
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:
- 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.
- 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.
- 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)