# Retro-computer 3D scene built entirely in code (Blender, no manual # modelling): a beige computer on a desk, warm sun through window blinds, # light haze, a slow camera push-in, and the given lines typed one after # another on the monitor. Vertical 1080x1920 for reels/Shorts. # Usage (Blender 5.2, from any folder): # blender -b --factory-startup -P cena-3d.py -- --texto "linha 1|linha 2|linha 3" --saida cena.mp4 # [--dur 8] [--quadros 1,120,240] (stills only, PNG, to check before the full video) # [--previa] (half size, fewer samples) import bpy, sys, math, os from mathutils import Vector FPS = 30 def args(): a = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [] o = {'texto': 'Isso não foi feito no Blender|Foi feito em código|Mais negócio, menos tempo perdido', 'saida': os.path.abspath('cena-3d.mp4'), 'dur': '8', 'quadros': '', 'previa': False} i = 0 while i < len(a): k = a[i].lstrip('-') if k == 'previa': o['previa'] = True else: i += 1 o[k] = a[i] i += 1 dur = float(o['dur']) if not 2 <= dur <= 60: raise SystemExit('--dur entre 2 e 60 s') linhas = [t.strip() for t in o['texto'].split('|') if t.strip()] if not linhas: raise SystemExit('--texto vazio') return linhas, os.path.abspath(o['saida']), dur, [int(q) for q in o['quadros'].split(',') if q.strip()], o['previa'] def mat(name, color, rough=0.5, emit=0.0, metal=0.0): m = bpy.data.materials.new(name) b = m.node_tree.nodes['Principled BSDF'] b.inputs['Base Color'].default_value = (*color, 1) b.inputs['Roughness'].default_value = rough b.inputs['Metallic'].default_value = metal if emit: b.inputs['Emission Color'].default_value = (*color, 1) b.inputs['Emission Strength'].default_value = emit return m def box(name, size, loc, m, bevel=0.0): bpy.ops.mesh.primitive_cube_add(location=loc) o = bpy.context.object o.name = name o.scale = (size[0] / 2, size[1] / 2, size[2] / 2) bpy.ops.object.transform_apply(scale=True) o.data.materials.append(m) if bevel: bv = o.modifiers.new('bevel', 'BEVEL') bv.width, bv.segments = bevel, 3 bpy.ops.object.shade_smooth() return o def cyl(name, r, h, loc, m): bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=h, location=loc, vertices=32) o = bpy.context.object o.name = name o.data.materials.append(m) bpy.ops.object.shade_smooth() return o def build(linhas, dur, previa): sc = bpy.context.scene for o in list(bpy.data.objects): bpy.data.objects.remove(o) bpy.context.scene.cursor.location = (0, 0, 0) frames = round(dur * FPS) sc.frame_start, sc.frame_end = 1, frames sc.render.fps = FPS sc.render.resolution_x, sc.render.resolution_y = 1080, 1920 sc.render.resolution_percentage = 50 if previa else 100 sc.render.engine = 'BLENDER_EEVEE' ee = sc.eevee ee.taa_render_samples = 16 if previa else 64 for attr, val in (('use_shadows', True), ('volumetric_end', 8.0), ('volumetric_tile_size', '4'), ('volumetric_shadow_samples', 32), ('use_volumetric_shadows', True), ('use_raytracing', True)): if hasattr(ee, attr): setattr(ee, attr, val) sc.render.use_motion_blur = True try: sc.view_settings.view_transform = 'AgX' sc.view_settings.look = 'AgX - Medium High Contrast' except TypeError: pass # look names change between versions; the default look still renders # world: dim warm ambience w = bpy.data.worlds.new('mundo') sc.world = w w.node_tree.nodes['Background'].inputs['Color'].default_value = (0.05, 0.035, 0.03, 1) w.node_tree.nodes['Background'].inputs['Strength'].default_value = 0.4 # haze only in a box around the desk: a world volume (even at density 0.006) # blocked the sun completely in EEVEE 5.2 (tested 10/11) bpy.ops.mesh.primitive_cube_add(location=(0, -0.3, 1.3)) nev = bpy.context.object nev.name, nev.scale = 'nevoa', (1.6, 1.3, 1.3) mn = bpy.data.materials.new('nevoa') nodes = mn.node_tree.nodes nodes.remove(nodes['Principled BSDF']) vol = nodes.new('ShaderNodeVolumePrincipled') vol.inputs['Density'].default_value = 0.12 vol.inputs['Color'].default_value = (1, 0.85, 0.7, 1) mn.node_tree.links.new(vol.outputs[0], nodes['Material Output'].inputs['Volume']) nev.data.materials.append(mn) bege = mat('bege', (0.72, 0.56, 0.34), 0.45) # AgX desaturates, so the base colors run warm madeira = mat('madeira', (0.16, 0.035, 0.02), 0.35) parede = mat('parede', (0.55, 0.42, 0.3), 0.8) escuro = mat('escuro', (0.03, 0.03, 0.03), 0.6) tela = mat('tela', (0.75, 0.82, 0.9), 0.3, emit=2.2) tinta = mat('tinta', (0.02, 0.025, 0.04), 0.5) vaso = mat('vaso', (0.5, 0.12, 0.06), 0.5) folha = mat('folha', (0.08, 0.22, 0.06), 0.6) box('chao', (8, 8, 0.02), (0, 0, -0.01), madeira) box('parede', (8, 0.05, 4), (0, 0.65, 2), parede) box('mesa', (1.7, 0.95, 0.05), (0, 0.05, 0.75), madeira, 0.006) top = 0.775 box('gabinete', (0.46, 0.42, 0.11), (0, 0.05, top + 0.055), bege, 0.012) box('drive', (0.16, 0.01, 0.012), (0.1, -0.165, top + 0.06), escuro) mon_z = top + 0.11 + 0.17 box('monitor', (0.38, 0.36, 0.34), (0, 0.06, mon_z), bege, 0.03) box('moldura', (0.33, 0.01, 0.26), (0, -0.121, mon_z + 0.01), escuro, 0.01) bpy.ops.mesh.primitive_plane_add(size=1, location=(0, -0.127, mon_z + 0.01), rotation=(math.pi / 2, 0, 0)) scr = bpy.context.object scr.name = 'tela' scr.scale = (0.29, 0.22, 1) scr.data.materials.append(tela) kb = box('teclado', (0.44, 0.15, 0.025), (0, -0.33, top + 0.0125), bege, 0.006) kb.rotation_euler.x = math.radians(4) key = box('tecla', (0.022, 0.022, 0.012), (-0.2, -0.38, top + 0.03), bege, 0.003) for axis, n, step in ((0, 15, 0.0275), (1, 4, 0.03)): ar = key.modifiers.new(f'arr{axis}', 'ARRAY') ar.count, ar.use_relative_offset, ar.use_constant_offset = n, False, True ar.constant_offset_displace = (step, 0, 0) if axis == 0 else (0, step, 0) cyl('caneca', 0.04, 0.1, (-0.42, -0.2, top + 0.05), mat('caneca', (0.85, 0.82, 0.75), 0.3)) cyl('vaso', 0.06, 0.12, (0.5, 0.15, top + 0.06), vaso) for i in range(7): bpy.ops.mesh.primitive_cone_add(radius1=0.025, depth=0.28, location=(0.5, 0.15, top + 0.24), rotation=(math.radians(18 * math.sin(i * 2.4)), math.radians(18 * math.cos(i * 2.4)), 0)) bpy.context.object.data.materials.append(folha) # window blinds off-frame on the left: the sun through them paints stripes for i in range(16): box(f'persiana{i}', (0.04, 3.0, 0.07), (-1.3, -0.5, 0.6 + i * 0.13), escuro) bpy.ops.object.light_add(type='SUN', rotation=(math.radians(62), 0, math.radians(-68))) sun = bpy.context.object.data sun.energy, sun.color, sun.angle = 9.0, (1.0, 0.45, 0.16), math.radians(0.6) bpy.ops.object.light_add(type='AREA', location=(1.4, -1.2, 1.8)) fill = bpy.context.object fill.data.energy, fill.data.color, fill.data.size = 60, (0.45, 0.55, 1.0), 1.5 fill.rotation_euler = (math.radians(60), 0, math.radians(50)) # screen text: one line at a time, each in its own slot of the video slot = frames / len(linhas) for i, t in enumerate(linhas): bpy.ops.object.text_add(location=(0, -0.1285, mon_z + 0.01), rotation=(math.pi / 2, 0, 0)) tx = bpy.context.object tx.data.body = t tx.data.align_x, tx.data.align_y = 'CENTER', 'CENTER' tx.data.size = min(0.034, 0.25 / max(8, len(t)) * 1.9) tx.data.text_boxes[0].width = 0.25 tx.data.text_boxes[0].x = -0.125 tx.data.materials.append(tinta) a, b = round(i * slot) + 1, round((i + 1) * slot) keys = [(1, True), (a, False)] if a > 1 else [(1, False)] if b < frames: keys.append((b + 1, True)) for f, hide in keys: # booleans keyframe as steps, no fades tx.hide_render = hide tx.keyframe_insert('hide_render', frame=f) # camera: slow push-in from a high three-quarter view to the screen, focus on it bpy.ops.object.empty_add(location=(0, -0.12, mon_z + 0.01)) alvo = bpy.context.object bpy.ops.object.camera_add(location=(0.95, -1.75, 1.35)) cam = bpy.context.object sc.camera = cam tr = cam.constraints.new('TRACK_TO') tr.target, tr.track_axis, tr.up_axis = alvo, 'TRACK_NEGATIVE_Z', 'UP_Y' cam.data.lens = 35 cam.data.dof.use_dof = True cam.data.dof.focus_object = alvo cam.data.dof.aperture_fstop = 2.8 cam.keyframe_insert('location', frame=1) cam.data.keyframe_insert('lens', frame=1) cam.location = (0.12, -0.72, 1.16) cam.data.lens = 42 cam.keyframe_insert('location', frame=frames) cam.data.keyframe_insert('lens', frame=frames) return frames def main(): linhas, saida, dur, quadros, previa = args() frames = build(linhas, dur, previa) sc = bpy.context.scene os.makedirs(os.path.dirname(saida), exist_ok=True) if quadros: base = os.path.splitext(saida)[0] sc.render.image_settings.file_format = 'PNG' for q in quadros: sc.frame_set(min(max(q, 1), frames)) sc.render.filepath = f'{base}-q{q:04d}.png' bpy.ops.render.render(write_still=True) return if hasattr(sc.render.image_settings, 'media_type'): sc.render.image_settings.media_type = 'VIDEO' # Blender 5.x: FFMPEG only exists under VIDEO sc.render.image_settings.file_format = 'FFMPEG' sc.render.ffmpeg.format = 'MPEG4' sc.render.ffmpeg.codec = 'H264' sc.render.ffmpeg.constant_rate_factor = 'HIGH' sc.render.filepath = saida bpy.ops.render.render(animation=True) main()