Vectors :- Vec2, Vec3, Vec4
A vector is the most important object in 3D graphics. In PyNGL a vector
is a small, fixed-size collection of float values:
| Class | Components | Typical uses |
|---|---|---|
Vec2 |
x, y |
texture coordinates, 2D positions, screen positions |
Vec3 |
x, y, z |
3D positions, directions, normals, colours (r, g, b) |
Vec4 |
x, y, z, w |
homogeneous positions, colours with alpha (r, g, b, a) |
All three share the same API, so once you know Vec3 you know them all.
This tutorial uses Vec3 for most examples.
Before you start: make sure you have read Understanding the Method Names. The single most important fact is that methods ending in
-edreturn a new vector — they never change the vector you call them on.
Creating vectors
from ncca.ngl import Vec2, Vec3, Vec4
a = Vec3(1.0, 2.0, 3.0) # positional components
b = Vec3(y=1.0) # keywords work too -> (0.0, 1.0, 0.0)
c = Vec3() # default -> (0.0, 0.0, 0.0)
d = Vec4() # default -> (0.0, 0.0, 0.0, 1.0) note w = 1!
Why does
Vec4defaultwto 1.0? AVec4usually represents a position in homogeneous coordinates, and positions havew = 1so that matrix translation affects them. Directions usew = 0. This matters in the matrices tutorial.
You can also build vectors from other types with the from_ classmethods,
and convert to other types with the to_ methods:
import numpy as np
v = Vec3.from_list([1.0, 2.0, 3.0])
w = Vec3.from_numpy(np.array([4.0, 5.0, 6.0]))
v.to_list() # [1.0, 2.0, 3.0]
v.to_tuple() # (1.0, 2.0, 3.0)
v.to_numpy() # np.float32 array ready to send to OpenGL / WebGPU
Reading and writing components
v = Vec3(1.0, 2.0, 3.0)
v.x # 1.0 — by name
v[0] # 1.0 — by index
x, y, z = v # vectors unpack like tuples
v.x = 10.0 # component assignment mutates (like set)
v.set(1.0, 2.0, 3.0) # set() replaces ALL components at once
Remember: set() and component assignment are the only ways to change
an existing vector. Everything else returns a new one.
Arithmetic
Vectors support the arithmetic you would expect, and every operation returns a new vector:
a = Vec3(1.0, 2.0, 3.0)
b = Vec3(4.0, 5.0, 6.0)
a + b # [5.0, 7.0, 9.0] component-wise add
a - b # [-3.0, -3.0, -3.0]
-a # [-1.0, -2.0, -3.0] negation (the opposite direction)
a * 2.0 # [2.0, 4.0, 6.0] scale by a number
2.0 * a # same order does not matter for scalars
a / 2.0 # [0.5, 1.0, 1.5]
a == Vec3(1.0, 2.0, 3.0) # True compares component values
Note:
*only works with a number (a scalar). You cannot writea * bfor two vectors — that would be ambiguous (dot product? cross product? component-wise?). PyNGL makes you say which one you mean:a.dot(b)ora.cross(b).
A worked example — moving a point
A very common pattern in animation: move a position along a direction.
position = Vec3(0.0, 0.0, 0.0)
velocity = Vec3(1.0, 2.0, 0.0)
dt = 0.1 # time step in seconds
position = position + velocity * dt # new position each frame
print(position) # [0.1, 0.2, 0.0]
Length and normalization
The length (or magnitude) of a vector is how long the arrow is:
v = Vec3(0.0, 3.0, 4.0)
v.length() # 5.0 (the 3-4-5 triangle!)
v.length_squared() # 25.0
Why
length_squared()? Computing a length needs a square root, which is relatively slow. If you only want to compare distances ("which enemy is closest?"), compare the squared lengths instead — the comparison gives the same answer and skips the square root.
A normalized vector (also called a unit vector) has length 1. Unit vectors represent pure direction with no magnitude, and almost every lighting and reflection formula requires them:
v = Vec3(0.0, 3.0, 4.0)
u = v.normalized() # NEW vector [0.0, 0.6, 0.8] with length 1.0
print(v) # [0.0, 3.0, 4.0] — v is unchanged!
A worked example — the direction from A to B
"Which way do I walk from start to target, and how far is it?"
start = Vec3(1.0, 0.0, 1.0)
target = Vec3(4.0, 0.0, 5.0)
offset = target - start # the arrow from start to target
distance = offset.length() # 5.0
direction = offset.normalized() # [0.6, 0.0, 0.8] unit direction
# walk 2 units towards the target:
new_position = start + direction * 2.0
This subtract, measure, normalize pattern appears constantly in graphics and games — learn it well.
The dot product
a.dot(b) returns a single number that measures the alignment of two
vectors. For unit vectors it is the cosine of the angle between them:
a.dot(b) (unit vectors) |
Meaning |
|---|---|
1.0 |
same direction (0°) |
0.0 |
perpendicular (90°) |
-1.0 |
opposite directions (180°) |
a = Vec3(1.0, 0.0, 0.0)
b = Vec3(0.0, 1.0, 0.0)
a.dot(b) # 0.0 perpendicular
a.dot(a) # 1.0 a vector always fully agrees with itself
a.dot(-a) # -1.0 opposite
A worked example :- simple diffuse lighting
The heart of the classic Lambert lighting model is one dot product: a surface is bright when its normal points at the light.
normal = Vec3(0.0, 1.0, 0.0) # surface faces up
to_light = Vec3(1.0, 1.0, 0.0).normalized() # direction to the light
brightness = max(0.0, normal.dot(to_light))
print(brightness) # 0.707... light at 45 degrees gives ~71% brightness
The max(0.0, ...) clamps away negative values a surface facing away
from the light is dark, not "negatively lit".
A worked example :- is it in front of me?
forward = Vec3(0.0, 0.0, -1.0) # camera looks down -z
to_object = (obj_pos - cam_pos).normalized()
if forward.dot(to_object) > 0.0:
print("the object is in front of the camera")
The cross product :- "give me a perpendicular vector"
a.cross(b) returns a new vector perpendicular to both a and b.
Its direction follows the right-hand rule, and its length equals the area
of the parallelogram the two vectors span.
x = Vec3(1.0, 0.0, 0.0)
y = Vec3(0.0, 1.0, 0.0)
x.cross(y) # [0.0, 0.0, 1.0] the z axis
y.cross(x) # [0.0, 0.0, -1.0] order matters! (anti-commutative)
Vec2is special: in 2D there is no third axis to point along, soVec2.cross()returns a single number (the signed area) instead of a vector. Positive meansbis anticlockwise froma.
A worked example :- the normal of a triangle
Given three corners of a triangle, the cross product of two edges gives the surface normal (used in lighting calculations).
p1 = Vec3(0.0, 0.0, 0.0)
p2 = Vec3(1.0, 0.0, 0.0)
p3 = Vec3(0.0, 1.0, 0.0)
edge1 = p2 - p1
edge2 = p3 - p1
normal = edge1.cross(edge2).normalized()
PyNGL wraps this exact pattern in a helper —
calc_normal(p1, p2, p3) — but you should
understand what it does inside.
Reflection :- bouncing off a surface
v.reflected(n) returns the vector v bounced off a surface whose unit
normal is n. Think of a ball hitting the floor, or light hitting a mirror:
incoming = Vec3(1.0, -1.0, 0.0) # travelling right and DOWN
floor_n = Vec3(0.0, 1.0, 0.0) # the floor's normal points UP
bounced = incoming.reflected(floor_n)
print(bounced) # [1.0, 1.0, 0.0] — right and UP
The formula inside is v - 2 * v.dot(n) * n. The normal n must be unit
length or the result will be wrong — normalize it first if in doubt.
A worked example — a bouncing ball
position = Vec3(0.0, 5.0, 0.0)
velocity = Vec3(2.0, 0.0, 0.0)
gravity = Vec3(0.0, -9.8, 0.0)
floor_n = Vec3(0.0, 1.0, 0.0)
dt = 1.0 / 60.0
for frame in range(600):
velocity = velocity + gravity * dt
position = position + velocity * dt
if position.y < 0.0: # hit the floor
position.y = 0.0
velocity = velocity.reflected(floor_n) * 0.8 # bounce, lose 20% energy
Clamping and interpolation
clamped(low, high) limits every component to a range — useful for
colours, which must stay in [0, 1]:
colour = Vec3(1.4, -0.2, 0.5)
safe = colour.clamped(0.0, 1.0) # [1.0, 0.0, 0.5]
lerp(other, t) (linear interpolation) blends from this vector to
another. t = 0 gives the start, t = 1 gives the end, t = 0.5 gives
the midpoint:
start = Vec3(0.0, 0.0, 0.0)
end = Vec3(10.0, 0.0, 0.0)
start.lerp(end, 0.25) # [2.5, 0.0, 0.0] — a quarter of the way there
A worked example — mixing between two colours
red = Vec3(1.0, 0.0, 0.0)
blue = Vec3(0.0, 0.0, 1.0)
for frame in range(101):
t = frame / 100.0
colour = red.lerp(blue, t) # smoothly red -> purple -> blue
Vec4 and the w component
Vec4 adds a fourth component, w. In graphics it has two main jobs:
1. Homogeneous coordinates. A Mat4 can only translate a point if the
point has w = 1:
point = Vec4(2.0, 3.0, 4.0) # w defaults to 1.0 -> a POSITION
direction = Vec4(0.0, 1.0, 0.0, 0.0) # w = 0.0 -> a DIRECTION
When multiplied by a Mat4, the position is affected by translation but the direction is not — which is exactly right: you can move a point, but "up" is still "up" no matter where you stand.
2. RGBA colours. Vec4(r, g, b, a) and here the default w = 1.0 also makes sense: fully opaque.
Common mistakes
Mistake 1 :- forgetting to keep the result of an -ed method.
v.normalized() # result thrown away, v unchanged
v = v.normalized() #
Mistake 2 :- normalizing the zero vector.
Vec3(0.0, 0.0, 0.0).normalized() # ❌ length is 0 you cannot divide by it
Guard with if v.length_squared() > 0.0: when the input might be zero.
Mistake 3 :- using * between two vectors.
a * b # ValueError — * is scalar-only
a.dot(b) # if you wanted the dot product
a.cross(b) # if you wanted the cross product
Mistake 4 :- comparing distances with length() in a loop.
if (a - b).length() < (a - c).length(): # works, but slow (2 sqrts)
if (a - b).length_squared() < (a - c).length_squared(): # same answer, faster
Mistake 5 :- reflecting off an unnormalized normal.
v.reflected(Vec3(0.0, 2.0, 0.0)) # normal has length 2 wrong answer
v.reflected(Vec3(0.0, 2.0, 0.0).normalized()) #
Quick reference
| Operation | Code | Returns |
|---|---|---|
| add / subtract | a + b, a - b |
new vector |
| scale | a * 2.0, a / 2.0 |
new vector |
| negate | -a |
new vector |
| length | a.length(), a.length_squared() |
float |
| unit vector | a.normalized() |
new vector |
| alignment | a.dot(b) |
float |
| perpendicular | a.cross(b) |
new vector (float for Vec2) |
| bounce | a.reflected(n) |
new vector |
| limit range | a.clamped(low, high) |
new vector |
| blend | a.lerp(b, t) |
new vector |
| copy | a.copy() |
new vector |
| mutate | a.set(x, y, z), a.x = 1.0 |
— (changes a) |
| convert | to_list/tuple/numpy, from_list/numpy |
conversions |
Next: Matrices — transforming vectors with rotation, scale, and translation.