forked from 3b1b/videos
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathlight.py
602 lines (496 loc) · 20.6 KB
/
light.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
from traceback import *
from scipy.spatial import ConvexHull
from manimlib.animation.composition import LaggedStartMap
from manimlib.animation.fading import FadeIn
from manimlib.animation.fading import FadeOut
from manimlib.animation.transform import Transform
from manimlib.constants import *
from manimlib.mobject.geometry import AnnularSector
from manimlib.mobject.geometry import Annulus
from manimlib.mobject.svg.svg_mobject import SVGMobject
from manimlib.mobject.types.vectorized_mobject import VMobject
from manimlib.mobject.types.vectorized_mobject import VectorizedPoint
from manimlib.utils.space_ops import angle_between_vectors
from manimlib.utils.space_ops import project_along_vector
from manimlib.utils.space_ops import rotate_vector
from manimlib.utils.space_ops import z_to_vector
LIGHT_COLOR = YELLOW
SHADOW_COLOR = BLACK
SWITCH_ON_RUN_TIME = 1.5
FAST_SWITCH_ON_RUN_TIME = 0.1
NUM_LEVELS = 30
NUM_CONES = 7 # in first lighthouse scene
NUM_VISIBLE_CONES = 5 # ibidem
ARC_TIP_LENGTH = 0.2
AMBIENT_FULL = 0.8
AMBIENT_DIMMED = 0.5
SPOTLIGHT_FULL = 0.8
SPOTLIGHT_DIMMED = 0.5
LIGHTHOUSE_HEIGHT = 0.8
DEGREES = TAU / 360
def inverse_power_law(maxint, scale, cutoff, exponent):
return (lambda r: maxint * (cutoff / (r / scale + cutoff))**exponent)
def inverse_quadratic(maxint, scale, cutoff):
return inverse_power_law(maxint, scale, cutoff, 2)
class SwitchOn(LaggedStartMap):
CONFIG = {
"lag_ratio": 0.2,
"run_time": SWITCH_ON_RUN_TIME
}
def __init__(self, light, **kwargs):
if (not isinstance(light, AmbientLight) and not isinstance(light, Spotlight)):
raise Exception(
"Only AmbientLights and Spotlights can be switched on")
LaggedStartMap.__init__(
self, FadeIn, light, **kwargs
)
class SwitchOff(LaggedStartMap):
CONFIG = {
"lag_ratio": 0.2,
"run_time": SWITCH_ON_RUN_TIME
}
def __init__(self, light, **kwargs):
if (not isinstance(light, AmbientLight) and not isinstance(light, Spotlight)):
raise Exception(
"Only AmbientLights and Spotlights can be switched off")
light.set_submobjects(light.submobjects[::-1])
LaggedStartMap.__init__(self, FadeOut, light, **kwargs)
light.set_submobjects(light.submobjects[::-1])
class Lighthouse(SVGMobject):
CONFIG = {
"height": LIGHTHOUSE_HEIGHT,
"fill_color": WHITE,
"fill_opacity": 1.0,
}
def __init__(self, **kwargs):
super().__init__("lighthouse", **kwargs)
def move_to(self, point):
self.next_to(point, DOWN, buff=0)
class AmbientLight(VMobject):
# Parameters are:
# * a source point
# * an opacity function
# * a light color
# * a max opacity
# * a radius (larger than the opacity's dropoff length)
# * the number of subdivisions (levels, annuli)
CONFIG = {
"source_point": VectorizedPoint(location=ORIGIN, stroke_width=0, fill_opacity=0),
"opacity_function": lambda r: 1.0 / (r + 1.0)**2,
"color": LIGHT_COLOR,
"max_opacity": 1.0,
"num_levels": NUM_LEVELS,
"radius": 5.0
}
def init_points(self):
# in theory, this method is only called once, right?
# so removing submobs shd not be necessary
#
# Note: Usually, yes, it is only called within Mobject.__init__,
# but there is no strong guarantee of that, and you may want certain
# update functions to regenerate points here and there.
for submob in self.submobjects:
self.remove(submob)
self.add(self.source_point)
# create annuli
self.radius = float(self.radius)
dr = self.radius / self.num_levels
for r in np.arange(0, self.radius, dr):
alpha = self.max_opacity * self.opacity_function(r)
annulus = Annulus(
inner_radius=r,
outer_radius=r + dr,
color=self.color,
fill_opacity=alpha
)
annulus.move_to(self.get_source_point())
self.add(annulus)
def move_source_to(self, point):
# old_source_point = self.get_source_point()
# self.shift(point - old_source_point)
self.move_to(point)
return self
def get_source_point(self):
return self.source_point.get_location()
def dimming(self, new_alpha):
old_alpha = self.max_opacity
self.max_opacity = new_alpha
for submob in self.submobjects:
old_submob_alpha = submob.fill_opacity
new_submob_alpha = old_submob_alpha * new_alpha / old_alpha
submob.set_fill(opacity=new_submob_alpha)
class Spotlight(VMobject):
CONFIG = {
"source_point": VectorizedPoint(location=ORIGIN, stroke_width=0, fill_opacity=0),
"opacity_function": lambda r: 1.0 / (r / 2 + 1.0)**2,
"color": GREEN, # LIGHT_COLOR,
"max_opacity": 1.0,
"num_levels": 10,
"radius": 10.0,
"screen": None,
"camera_mob": None
}
def projection_direction(self):
# Note: This seems reasonable, though for it to work you'd
# need to be sure that any 3d scene including a spotlight
# somewhere assigns that spotlights "camera" attribute
# to be the camera associated with that scene.
if self.camera_mob is None:
return OUT
else:
[phi, theta, r] = self.camera_mob.get_center()
v = np.array([np.sin(phi) * np.cos(theta),
np.sin(phi) * np.sin(theta), np.cos(phi)])
return v # /get_norm(v)
def project(self, point):
v = self.projection_direction()
w = project_along_vector(point, v)
return w
def get_source_point(self):
return self.source_point.get_location()
def init_points(self):
self.set_submobjects([])
self.add(self.source_point)
if self.screen is not None:
# look for the screen and create annular sectors
lower_angle, upper_angle = self.viewing_angles(self.screen)
self.radius = float(self.radius)
dr = self.radius / self.num_levels
lower_ray, upper_ray = self.viewing_rays(self.screen)
for r in np.arange(0, self.radius, dr):
new_sector = self.new_sector(r, dr, lower_angle, upper_angle)
self.add(new_sector)
def new_sector(self, r, dr, lower_angle, upper_angle):
alpha = self.max_opacity * self.opacity_function(r)
annular_sector = AnnularSector(
inner_radius=r,
outer_radius=r + dr,
color=self.color,
fill_opacity=alpha,
start_angle=lower_angle,
angle=upper_angle - lower_angle
)
# rotate (not project) it into the viewing plane
rotation_matrix = z_to_vector(self.projection_direction())
annular_sector.apply_matrix(rotation_matrix)
# now rotate it inside that plane
rotated_RIGHT = np.dot(RIGHT, rotation_matrix.T)
projected_RIGHT = self.project(RIGHT)
omega = angle_between_vectors(rotated_RIGHT, projected_RIGHT)
annular_sector.rotate(omega, axis=self.projection_direction())
annular_sector.move_arc_center_to(self.get_source_point())
return annular_sector
def viewing_angle_of_point(self, point):
# as measured from the positive x-axis
v1 = self.project(RIGHT)
v2 = self.project(np.array(point) - self.get_source_point())
absolute_angle = angle_between_vectors(v1, v2)
# determine the angle's sign depending on their plane's
# choice of orientation. That choice is set by the camera
# position, i. e. projection direction
if np.dot(self.projection_direction(), np.cross(v1, v2)) > 0:
return absolute_angle
else:
return -absolute_angle
def viewing_angles(self, screen):
screen_points = screen.get_anchors()
projected_screen_points = list(map(self.project, screen_points))
viewing_angles = np.array(list(map(self.viewing_angle_of_point,
projected_screen_points)))
lower_angle = upper_angle = 0
if len(viewing_angles) != 0:
lower_angle = np.min(viewing_angles)
upper_angle = np.max(viewing_angles)
if upper_angle - lower_angle > TAU / 2:
lower_angle, upper_angle = upper_angle, lower_angle + TAU
return lower_angle, upper_angle
def viewing_rays(self, screen):
lower_angle, upper_angle = self.viewing_angles(screen)
projected_RIGHT = self.project(
RIGHT) / get_norm(self.project(RIGHT))
lower_ray = rotate_vector(
projected_RIGHT, lower_angle, axis=self.projection_direction())
upper_ray = rotate_vector(
projected_RIGHT, upper_angle, axis=self.projection_direction())
return lower_ray, upper_ray
def opening_angle(self):
l, u = self.viewing_angles(self.screen)
return u - l
def start_angle(self):
l, u = self.viewing_angles(self.screen)
return l
def stop_angle(self):
l, u = self.viewing_angles(self.screen)
return u
def move_source_to(self, point):
self.source_point.set_location(np.array(point))
# self.source_point.move_to(np.array(point))
# self.move_to(point)
self.update_sectors()
return self
def update_sectors(self):
if self.screen is None:
return
for submob in self.submobjects:
if type(submob) == AnnularSector:
lower_angle, upper_angle = self.viewing_angles(self.screen)
# dr = submob.outer_radius - submob.inner_radius
dr = self.radius / self.num_levels
new_submob = self.new_sector(
submob.inner_radius, dr, lower_angle, upper_angle
)
# submob.points = new_submob.points
# submob.set_fill(opacity = 10 * self.opacity_function(submob.outer_radius))
Transform(submob, new_submob).update(1)
def dimming(self, new_alpha):
old_alpha = self.max_opacity
self.max_opacity = new_alpha
for submob in self.submobjects:
# Note: Maybe it'd be best to have a Shadow class so that the
# type can be checked directly?
if type(submob) != AnnularSector:
# it's the shadow, don't dim it
continue
old_submob_alpha = submob.fill_opacity
new_submob_alpha = old_submob_alpha * new_alpha / old_alpha
submob.set_fill(opacity=new_submob_alpha)
def change_opacity_function(self, new_f):
self.opacity_function = new_f
dr = self.radius / self.num_levels
sectors = []
for submob in self.submobjects:
if type(submob) == AnnularSector:
sectors.append(submob)
for (r, submob) in zip(np.arange(0, self.radius, dr), sectors):
if type(submob) != AnnularSector:
# it's the shadow, don't dim it
continue
alpha = self.opacity_function(r)
submob.set_fill(opacity=alpha)
# Warning: This class is likely quite buggy.
class LightSource(VMobject):
# combines:
# a lighthouse
# an ambient light
# a spotlight
# and a shadow
CONFIG = {
"source_point": VectorizedPoint(location=ORIGIN, stroke_width=0, fill_opacity=0),
"color": LIGHT_COLOR,
"num_levels": 10,
"radius": 10.0,
"screen": None,
"opacity_function": inverse_quadratic(1, 2, 1),
"max_opacity_ambient": AMBIENT_FULL,
"max_opacity_spotlight": SPOTLIGHT_FULL,
"camera_mob": None
}
def init_points(self):
self.add(self.source_point)
self.lighthouse = Lighthouse()
self.ambient_light = AmbientLight(
source_point=VectorizedPoint(location=self.get_source_point()),
color=self.color,
num_levels=self.num_levels,
radius=self.radius,
opacity_function=self.opacity_function,
max_opacity=self.max_opacity_ambient
)
if self.has_screen():
self.spotlight = Spotlight(
source_point=VectorizedPoint(location=self.get_source_point()),
color=self.color,
num_levels=self.num_levels,
radius=self.radius,
screen=self.screen,
opacity_function=self.opacity_function,
max_opacity=self.max_opacity_spotlight,
camera_mob=self.camera_mob
)
else:
self.spotlight = Spotlight()
self.shadow = VMobject(fill_color=SHADOW_COLOR,
fill_opacity=1.0, stroke_color=BLACK)
self.lighthouse.next_to(self.get_source_point(), DOWN, buff=0)
self.ambient_light.move_source_to(self.get_source_point())
if self.has_screen():
self.spotlight.move_source_to(self.get_source_point())
self.update_shadow()
self.add(self.ambient_light, self.spotlight,
self.lighthouse, self.shadow)
def has_screen(self):
if self.screen is None:
return False
elif self.screen.get_num_points() == 0:
return False
else:
return True
def dim_ambient(self):
self.set_max_opacity_ambient(AMBIENT_DIMMED)
def set_max_opacity_ambient(self, new_opacity):
self.max_opacity_ambient = new_opacity
self.ambient_light.dimming(new_opacity)
def dim_spotlight(self):
self.set_max_opacity_spotlight(SPOTLIGHT_DIMMED)
def set_max_opacity_spotlight(self, new_opacity):
self.max_opacity_spotlight = new_opacity
self.spotlight.dimming(new_opacity)
def set_camera_mob(self, new_cam_mob):
self.camera_mob = new_cam_mob
self.spotlight.camera_mob = new_cam_mob
def set_screen(self, new_screen):
if self.has_screen():
self.spotlight.screen = new_screen
else:
# Note: See below
index = self.submobjects.index(self.spotlight)
# camera_mob = self.spotlight.camera_mob
self.remove(self.spotlight)
self.spotlight = Spotlight(
source_point=VectorizedPoint(location=self.get_source_point()),
color=self.color,
num_levels=self.num_levels,
radius=self.radius,
screen=new_screen,
camera_mob=self.camera_mob,
opacity_function=self.opacity_function,
max_opacity=self.max_opacity_spotlight,
)
self.spotlight.move_source_to(self.get_source_point())
# Note: This line will make spotlight show up at the end
# of the submojects list, which can make it show up on
# top of the shadow. To make it show up in the
# same spot, you could try the following line,
# where "index" is what I defined above:
self.submobjects.insert(index, self.spotlight)
# self.add(self.spotlight)
# in any case
self.screen = new_screen
def move_source_to(self, point):
apoint = np.array(point)
v = apoint - self.get_source_point()
# Note: As discussed, things stand to behave better if source
# point is a submobject, so that it automatically interpolates
# during an animation, and other updates can be defined wrt
# that source point's location
self.source_point.set_location(apoint)
# self.lighthouse.next_to(apoint,DOWN,buff = 0)
# self.ambient_light.move_source_to(apoint)
self.lighthouse.shift(v)
# self.ambient_light.shift(v)
self.ambient_light.move_source_to(apoint)
if self.has_screen():
self.spotlight.move_source_to(apoint)
self.update()
return self
def change_spotlight_opacity_function(self, new_of):
self.spotlight.change_opacity_function(new_of)
def set_radius(self, new_radius):
self.radius = new_radius
self.ambient_light.radius = new_radius
self.spotlight.radius = new_radius
def update(self):
self.update_lighthouse()
self.update_ambient()
self.spotlight.update_sectors()
self.update_shadow()
def update_lighthouse(self):
self.lighthouse.move_to(self.get_source_point())
# new_lh = Lighthouse()
# new_lh.move_to(ORIGIN)
# new_lh.apply_matrix(self.rotation_matrix())
# new_lh.shift(self.get_source_point())
# self.lighthouse.submobjects = new_lh.submobjects
def update_ambient(self):
new_ambient_light = AmbientLight(
source_point=VectorizedPoint(location=ORIGIN),
color=self.color,
num_levels=self.num_levels,
radius=self.radius,
opacity_function=self.opacity_function,
max_opacity=self.max_opacity_ambient
)
new_ambient_light.apply_matrix(self.rotation_matrix())
new_ambient_light.move_source_to(self.get_source_point())
self.ambient_light.set_submobjects(new_ambient_light.submobjects)
def get_source_point(self):
return self.source_point.get_location()
def rotation_matrix(self):
if self.camera_mob is None:
return np.eye(3)
phi = self.camera_mob.get_center()[0]
theta = self.camera_mob.get_center()[1]
R1 = np.array([
[1, 0, 0],
[0, np.cos(phi), -np.sin(phi)],
[0, np.sin(phi), np.cos(phi)]
])
R2 = np.array([
[np.cos(theta + TAU / 4), -np.sin(theta + TAU / 4), 0],
[np.sin(theta + TAU / 4), np.cos(theta + TAU / 4), 0],
[0, 0, 1]
])
R = np.dot(R2, R1)
return R
def update_shadow(self):
point = self.get_source_point()
projected_screen_points = []
if not self.has_screen():
return
for point in self.screen.get_anchors():
projected_screen_points.append(self.spotlight.project(point))
projected_source = project_along_vector(
self.get_source_point(), self.spotlight.projection_direction())
projected_point_cloud_3d = np.append(
projected_screen_points,
np.reshape(projected_source, (1, 3)),
axis=0
)
# z_to_vector(self.spotlight.projection_direction())
rotation_matrix = self.rotation_matrix()
back_rotation_matrix = rotation_matrix.T # i. e. its inverse
rotated_point_cloud_3d = np.dot(
projected_point_cloud_3d, back_rotation_matrix.T)
# these points now should all have z = 0
point_cloud_2d = rotated_point_cloud_3d[:, :2]
# now we can compute the convex hull
hull_2d = ConvexHull(point_cloud_2d) # guaranteed to run ccw
hull = []
# we also need the projected source point
source_point_2d = np.dot(self.spotlight.project(
self.get_source_point()), back_rotation_matrix.T)[:2]
index = 0
for point in point_cloud_2d[hull_2d.vertices]:
if np.all(np.abs(point - source_point_2d) < 1.0e-6):
source_index = index
index += 1
continue
point_3d = np.array([point[0], point[1], 0])
hull.append(point_3d)
index += 1
hull_mobject = VMobject()
hull_mobject.set_points_as_corners(hull)
hull_mobject.apply_matrix(rotation_matrix)
anchors = hull_mobject.get_anchors()
# add two control points for the outer cone
if np.size(anchors) == 0:
self.shadow.resize_points(0)
return
ray1 = anchors[source_index - 1] - projected_source
ray1 = ray1 / get_norm(ray1) * 100
ray2 = anchors[source_index] - projected_source
ray2 = ray2 / get_norm(ray2) * 100
outpoint1 = anchors[source_index - 1] + ray1
outpoint2 = anchors[source_index] + ray2
new_anchors = anchors[:source_index]
new_anchors = np.append(new_anchors, np.array(
[outpoint1, outpoint2]), axis=0)
new_anchors = np.append(new_anchors, anchors[source_index:], axis=0)
self.shadow.set_points_as_corners(new_anchors)
# shift it closer to the camera so it is in front of the spotlight
self.shadow.mark_paths_closed = True
# Redefining what was once a ContinualAnimation class
# as a function
def ScreenTracker(light_source):
light_source.add_updater(lambda m: m.update())
return light_source