Building a Procedural 'Rope' Effect in PlayBASIC
Scanlines, Gouraud Shading, and Pixel Format Testing
At first glance, this demo looks like a twisting, three-dimensional rope floating over a smoothly scrolling, colour-banded background. It animates fluidly, reacts to user input, and even lets you switch pixel formats in real time.
What makes it interesting is how little machinery is actually involved.
There’s no 3D engine.
- No meshes.
- No textures.
- No z-buffer.
Instead, the entire effect is built using 2D horizontal gouraud strips, some trigonometry, and a deliberate approach to colour interpolation. This makes it not only visually appealing, but also an excellent example of how PlayBASIC encourages efficient, low-level graphics thinking.
What This Demo Is Designed to Show
This program serves three purposes at once:
- 1. Demonstrate how complex, organic effects can be built from simple 2D primitives
- 2. Showcase PlayBASIC’s gouraud rendering and colour interpolation
- 3. Stress-test different pixel formats (15, 16, 24, and 32-bit) using the same effect
You can switch pixel formats at runtime using F5–F8, and press Space to increase the number of edges used to construct the rope.
Rendering to an FXImage (Why It Matters)
Rather than drawing directly to the screen, the demo renders everything into an FXImage.
This allows the program to:
- Recreate the render target on demand
- Change pixel depth without restarting
- Ensure the exact same effect is rendered under different colour formats
When the user presses one of the function keys, the FXImage is deleted and recreated at the requested depth. The rendering logic itself doesn’t change — only the underlying pixel format does.
This makes visual differences between colour depths immediately obvious, especially in gradients and gouraud shading.
The Background: Colour Bands Built One Scanline at a Time
The background consists of vertically repeating colour bands that slowly scroll upward. Each band blends smoothly into the next, creating a soft, continuous gradient.
Pleasant Colours via Golden Ratio Stepping
Instead of random colours, the palette is generated using a golden-ratio hue step. This ensures evenly spaced hues around the colour wheel and avoids clusters or muddy transitions.
`NiceRGB()` advances the hue by ~0.618 each step, producing visually balanced colour sequences.
Horizontal Gouraud Strips
Each scanline is rendered using multiple horizontal gouraud strips, each with its own start and end colour. Because this happens line-by-line, the result is a smooth vertical gradient with excellent control over colour precision.
This alone already provides a strong test of pixel formats:
- Lower bit depths show banding earlier
- Higher depths preserve smoother blends
The Rope: A 3D Illusion Built from 2D Spans
The rope itself looks three-dimensional, but it isn’t.
At its core, the rope is a rotating ring built from multiple edges arranged in a circle. Each edge is treated as a point in 3D space (X and Z), then projected into screen space.
Screen X = `(X * focal_length) / Z`
Each pair of adjacent edges becomes a horizontal span — a left and right X coordinate with a colour at each end.
Only spans that face the camera (left X ≤ right X) are kept, which naturally removes back-facing segments without explicit clipping logic.
Fake Lighting via Depth-Scaled Colour
There’s no lighting model in this demo — and it doesn’t need one.
Instead, colour brightness is scaled based on depth:
> RGB values are multiplied by a constant and divided by Z
As a segment moves farther away, its colour darkens. As it moves closer, it brightens. This simple relationship produces a convincing sense of depth and curvature, especially when combined with smooth interpolation.
Because this happens before the colour is packed into the target pixel format, it also makes colour precision differences between formats very obvious.
One Rope, Many Scanlines
A key optimisation in this demo is that the rope geometry is calculated once per frame, not once per scanline.
The projected spans are stored in an array, and then reused for every scanline. The illusion of movement comes from shifting those spans horizontally as the screen is drawn.
This is where the rope gets its organic motion.
Motion Through Layered Waves
Instead of bending geometry, the demo applies several cosine-based offsets per scanline:
- A slow, wide wave
- A faster, tighter wave
- Additional ripples layered on top
Each scanline adds these offsets together, producing a flowing, ribbon-like motion that feels far more complex than it really is.
Geometry stays the same — only the X offset changes per scanline.
This technique is extremely efficient and works beautifully with scanline-based rendering.
Why Horizontal Gouraud Strips Are Ideal Here
Every part of this demo is built around horizontal gouraud strips, and that’s very intentional.
They are:
- Cache-friendly
- Deterministic
- Perfectly suited to scanline effects
- Consistent across all pixel formats
Using the same primitive for both the background and the rope ensures consistent behaviour and makes the demo easier to reason about.
Scaling the Effect
Pressing Space increases the number of rope edges. This:
- Increases the number of spans
- Adds visual complexity
- Stresses colour interpolation further
Yet the core rendering logic remains unchanged. The effect scales naturally, which is a hallmark of a good procedural design.
Final Thoughts
This demo is a great example of PlayBASIC’s strengths:
- Low-level control without unnecessary complexity
- Powerful 2D primitives that can fake 3D convincingly
- A rendering model that encourages efficient thinking
Most importantly, it shows that good visual effects are about understanding perception, not just throwing more technology at the problem.
Full Source Code
The complete PlayBASIC source code for this demo is included below for those who want to explore it in detail, experiment with it, or adapt the techniques for their own projects.
Happy coding — and enjoy bending pixels the old-school way.
; This is pixel format test version of this demo
; Function keys f5/f6/f7/f8 to set the display depth
; Space key to add more sides of objects
; open a tall strip like screen for the short
openscreen 800,1000,32,1
PositionScreen GetSCreenXpos(),0
; Load a new bigger font over the default font
LoadFont "Courier New",1,48,0,8
; set the font rendering mode of font #1 to alpha blend
fontdrawmode 1,1
; force a known randomize sequence by seeding the generator
randomize 8262
Global RopeEdges=3
Dim WaveRippleAngle#(10)
ScreenWidth =GetScreenWidth()
ScreenHeight =GetScreenHeight()
ScreenDepth=32
Screen= NewFXImage(ScreenWidth,ScreenHeight)
Type RopeVerts
X1#,X2#, rgb1,rgb2
endtype
For Tests=0 to 20
; init the palette
Dim Palette(RopeEdges+2)
For lp=0 to RopeEdges-1
Palette(lp) =NiceRGB(lp,0.25,0.90)
next
Palette(lp+1) =Palette(0)
Dim RopeSegs(Ropeedges) as Ropeverts
Do
if functionkeys(5) then ScreenDepth = 15
if functionkeys(6) then ScreenDepth = 16
if functionkeys(7) then ScreenDepth = 24
if functionkeys(8) then ScreenDepth = 32
if GetIMageDepth(Screen) <> ScreenDepth
deleteimage Screen
Screen =GetFreeImage()
CReateFXImageEX Screen,GetScreenWidth(),GetScreenHeight(),ScreenDepth
endif
rendertoimage Screen
// backdrop
X1=ScreenWidth *0.00
X2=ScreenWidth *0.25
X3=SCreenWidth *0.50
X4=SCreenWidth *0.75
X5=SCreenWidth *1.00
lockbuffer
PaletteBandSize=50
ThisRGB1=Palette(0)
for ylp=0 to getscreenheight()-1
YOffset = mod(Ylp+ScrollY,PaletteBandSize*(RopeEdges-1))
Index = mod(yoffset/PaletteBandSize,(RopeEdges-1))
ThisRGB1=Palette(Index)
ThisRGB2=Palette(Index+2)
// Scale /blend level between bands
Scale# = (Yoffset-(PaletteBandSize*Index)) / float(PaletteBandSize)
ThisRGB = RgbAlphaBlend(ThisRGB1,ThisRGB2, Scale#*80)
ThisRGB50 = Rgbfade(ThisRGB,50)
ThisRGB20 = Rgbfade(ThisRGB,10)
GouraudStripH x1,ThisRGB,X2,ThisRGB50,Ylp
GouraudStripH x2,ThisRGB50,X3,ThisRGB20,Ylp
GouraudStripH x3,ThisRGB20,X4,ThisRGB50,Ylp
GouraudStripH x4,ThisRGB50,X5,ThisRGB,Ylp
next
DrawRope()
unlockbuffer
ScrollY=mod(ScrollY+1,PaletteBandSize*(RopeEdges-1))
renderimage Screen,0,0
Sync
loop spacekey()
RopeEdges++
flushkeys
next
end
Function DrawRope()
Static RadiusAngle
RadiusAngle=wrapangle(RadiusAngle +1)
radius=250+cos(RadiusChange)*50
ScReenHeight=GetScreenHeight()
basex=GetScreenwidth()/2
// step the global angles
AngleStep=2
WaveAngle#= WaveRippleAngle#(1)
WaveAngle2#= WaveRippleAngle#(2)
WaveAngle3#= WaveRippleAngle#(3)
WaveAngle4#= WaveRippleAngle#(4)
WaveRippleAngle#(1)=wrapangle(WaveAngle#,0.7*AngleStep)
WaveRippleAngle#(2)=wrapangle(WaveAngle2#,1.2*AngleStep)
WaveRippleAngle#(3)=wrapangle(WaveAngle3#,2.1*AngleStep)
WaveRippleAngle#(4)=wrapangle(WaveAngle4#,-0.4*AngleStep)
EdgeAngleStep#=360.0/(RopeEdges)
BaseZ=1000
Segments=0
static BaseAngle#
ThisRGB1 = Palette(RopeEdges)
For lp=0 To RopeEdges-1
ThisRGB2 = Palette(lp+1)
Angle#=BaseAngle#+(EdgeAngleStep#*lp)
Angle2#=Angle#+EdgeAngleStep#
X1#=Cosradius(angle#,Radius)
X2#=CosRadius(angle2#,Radius)
Z1#=basez+SinRadius(angle#,Radius)
Z2#=basez+SinRadius(angle2#,Radius)
Px1#=((X1#*400)/z1#)
Px2#=((X2#*400)/z2#)
If pX1#<=pX2#
R=RgbR(ThisRgb1)
G=RgbG(ThisRgb1)
B=RgbB(ThisRGB1)
R1=(R*500)/z1#
G1=(G*500)/z1#
B1=(B*500)/z1#
R=RgbR(ThisRgb2)
G=RgbG(ThisRgb2)
B=RgbB(ThisRGB2)
R2=(R*500)/z2#
G2=(G*500)/z2#
B2=(B*500)/z2#
RopeSegs(Segments).x1=px1#
RopeSegs(Segments).x2=px2#
RopeSegs(Segments).rgb1=rgb(r1,g1,b1)
RopeSegs(Segments).rgb2=rgb(r2,g2,b2)
inc segments
EndIf
ThisRGB1=ThisRGB2
Next
dec segments
For ThisScanLine=0 To ScreenHeight-1
Xoffset=basex+cosradius(wAVEangle#,50)
Xoffset=Xoffset+cosradius(wAVEangle2#+wAVEangle#,40)
Xoffset=Xoffset+cosradius(wAVEangle3#,80)
Xoffset=Xoffset+cosradius(wAVEangle4#,30)
For lp=0 to Segments
GouraudStripH Xoffset+RopeSegs(lp).x1,RopeSegs(lp).rgb1,Xoffset+RopeSegs(lp).x2,RopeSegs(lp).rgb2,ThisScanline
; GouraudStripV Xoffset+RopeSegs(lp).x1,RopeSegs(lp).rgb1,Xoffset+RopeSegs(lp).x2,RopeSegs(lp).rgb2,ThisScanline
next
waveangle# += 0.15
waveangle2# += 0.25
waveangle3# += 0.35
waveangle4# += 0.33
Next
BaseAngle#++
EndFunction
; ===============================================================
; Golden Ratio based pleasant random colors
; - Each call with incrementing index gives nicely spaced hues
; ===============================================================
constant GoldenRatio# = 0.6180339 ; ˜ (v5 - 1) / 2
Function NiceRGB(Index=0, Saturation#=0.75, Value#=0.88)
Local Hue#
; Start from a random base hue, then walk by golden ratio
; (mod 1.0 to wrap around colour wheel)
Hue# = mod(Rnd#(1) + (Index * GoldenRatio#),1)
Hue# = Hue# - Floor(Hue#) ; fractional part only (mod 1)
; Optional: You can randomise S/V a little for more variety
Saturation# = Saturation# + RndRange#(-0.15, 0.15)
Value# = Value# + RndRange#(-0.1, 0.1)
; Clamp them
If Saturation# < 0.4 Then Saturation#=0.4
If Saturation# > 0.98 Then Saturation#=0.98
If Value# < 0.5 Then Value#=0.5
If Value# > 0.98 Then Value#=0.98
#if playbasicversion >=165
Result = HSV2RGB(Hue#, Saturation#, Value#)
#else
Result=rndrgb()
#Endif
EndFunction Result
Function RenderImage(ThisImage,xpos,ypos,Mode=false)
if GetIMageStatus(ThisIMAGE)
inkmode 1+2048
boxc 00,18,275,112,true,$a0a0a0
inkmode 1
Xpos+=10
Ypos+=20
ink $80fFFFFF
Text XPos,Ypos ," Fps:"+Str$(fps())
Text XPos,Ypos+40 ,"Depth:"+Str$(GetIMageDepth(ThisIMage))
rendertoscreen
lockbuffer
drawimage ThisImage,0,0,false
unlockbuffer
endif
EndFunction