0
$\begingroup$

I know it's possible to make a plane always face the Camera using a Damped Track constraint, as described here: How can I make a plane always look at camera? However, this only works when you're viewing the scene through the Camera.

demo

Is there a way to achieve the same effect, but have the plane face the active Viewport instead of the fixed Camera? Alternatively, is it possible to overlay an image directly in the 3D Viewport? I know I could use the Image Editor for reference, but I’d rather avoid splitting the interface. I want to keep the 3D Viewport as large as possible.

I'm also aware of the option to draw overlays using the GPU module, but those are flat screen-space drawings and can’t be interacted with or moved around in 3D space.

Image Editor

I need this to work in Solid mode. It seems that using Python to access and respond to the viewport's view matrix is the only way to achieve this effect?

$\endgroup$
4
  • $\begingroup$ The viewport navigation is not meant to modify data and does not generate scene updates, only redraw. The hack can be a timer or mouse events based or a redraw function that would propagate the update to a timer/depsgraph/modal execution because you cannot modify the scene in the draw part of the main loop. $\endgroup$ Commented Jul 3 at 12:39
  • $\begingroup$ If it's to show image references, maybe you can use the gpu module to draw on the viewport area directly? $\endgroup$ Commented Jul 7 at 1:42
  • 1
    $\begingroup$ A bit old, but could this lead to a solution? python - Viewport position and direction - Blender Stack Exchange $\endgroup$ Commented Jul 7 at 2:27
  • 1
    $\begingroup$ Use Pureref: pureref.com $\endgroup$ Commented Jul 10 at 12:50

3 Answers 3

4
+100
$\begingroup$

Proof of concept:

sprites demonstration gif

The following script lets you display an image directly in the 3D Viewport, attached to every visible empty object. It uses a custom GPU shader to draw the image so that it always faces the camera. The image automatically updates and stays in place as you move around the scene.

import bpy import gpu import gpu_extras import mathutils path = r'D:\Desktop\ilike2\NoAnimalTesting.png' image = bpy.data.images.load(path, check_existing=True) #image.alpha_mode = 'PREMUL' # useful for some alpha images texture = gpu.texture.from_image(image) vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") vert_out.smooth('VEC2', "uvInterp") shader_info = gpu.types.GPUShaderCreateInfo() shader_info.push_constant('MAT4', "the_matrix") shader_info.sampler(0, 'FLOAT_2D', "the_texture") shader_info.vertex_in(0, 'VEC2', "position") shader_info.vertex_in(1, 'VEC2', "uv") shader_info.vertex_out(vert_out) shader_info.fragment_out(0, 'VEC4', "FragColor") shader_info.vertex_source(""" void main() { uvInterp = uv; gl_Position = the_matrix * vec4(position, 0.0, 1.0); } """) shader_info.fragment_source(""" void main() { FragColor = texture(the_texture, uvInterp); } """) shader = gpu.shader.create_from_info(shader_info) batch = gpu_extras.batch.batch_for_shader( shader, 'TRI_FAN', { "position": ((-1, -1), (1, -1), (1, 1), (-1, 1)), "uv": ((0, 0), (1, 0), (1, 1), (0, 1)), }, ) def my_cool_draw(): """ draw an image for every empty object """ init_depth_test = gpu.state.depth_test_get() init_depth_mask = gpu.state.depth_mask_get() init_blend = gpu.state.blend_get() gpu.state.depth_test_set('LESS_EQUAL') #gpu.state.depth_test_set('ALWAYS') # draw in front gpu.state.depth_mask_set(False) gpu.state.blend_set('ALPHA') region_3d = bpy.context.region_data shader.uniform_sampler('the_texture', texture) view_point_location = region_3d.view_matrix.inverted().translation viewport_rotation = region_3d.view_matrix.transposed().to_quaternion() if region_3d.view_perspective == 'ORTHO': direction = mathutils.Vector((0,0,1)) direction.rotate(region_3d.view_matrix.inverted().to_quaternion()) view_point_location += direction * region_3d.view_distance the_matrix = region_3d.perspective_matrix @ mathutils.Matrix.Translation((0, 0, 0)) @ mathutils.Matrix.Scale(1, 4) empties = [o for o in bpy.data.objects if o.type == 'EMPTY' and o.visible_get()] empties.sort(key = lambda o: (o.location - view_point_location).length_squared, reverse=True) for empty in empties: loc, _ , sca = empty.matrix_world.decompose() shader.uniform_float('the_matrix', the_matrix @ mathutils.Matrix.LocRotScale(loc, viewport_rotation, sca)) batch.draw(shader) gpu.state.blend_set(init_blend) gpu.state.depth_mask_set(init_depth_mask) gpu.state.depth_test_set(init_depth_test) # set draw handler and delete the prev one if not hasattr(bpy.types.WindowManager, 'my_data'): bpy.types.WindowManager.my_data = {} wm = bpy.data.window_managers[0] region_type = 'WINDOW' try: bpy.types.SpaceView3D.draw_handler_remove(wm.my_data.get('prev_draw_handler'), region_type) except Exception: import traceback traceback.print_exc() finally: draw_handler = bpy.types.SpaceView3D.draw_handler_add(my_cool_draw, (), region_type, 'POST_VIEW') wm.my_data['prev_draw_handler'] = draw_handler # redraw to see changes without interacting with the viewport for area in bpy.context.window.screen.areas: if area.type == 'VIEW_3D': area.tag_redraw() 

If you want it to display only for a specific empty, for example, one named "Empty_Reference", you can simply add another condition alongside o.type == 'EMPTY', like this: o.name == "Empty_Reference".

$\endgroup$
3
  • $\begingroup$ Wow, this is amazing! thank you so much! This is exactly what I needed. I'll award the 100-point bounty as soon as it expires. The solution is also incredibly versatile since we can control the Empty’s position to keep it anchored at a specific location. $\endgroup$ Commented Jul 11 at 0:13
  • $\begingroup$ I shared a script here that keeps the Empty locked in place in the viewport. It works, but the movement feels a bit jittery or laggy, since I had to rely on a timer updating every 0.01 seconds. I don’t think this can be done with a depsgraph update, can it?" $\endgroup$ Commented Jul 12 at 23:10
  • $\begingroup$ @HarryMcKenzie The lag is because the viewport data is outdated, you need to call RegionView3D.update() $\endgroup$ Commented Jul 14 at 13:15
4
$\begingroup$

Alternative 1: World texture (material preview mode)

It's possible to do it with a world texture:

Example

World texture

Go to the shading tab, select the "World" and add an image texture:

  • Coordinates must be from Window
  • A mapping lets you adjust size and position
  • Image texture must be set to "clip" instead of repeat

World texture

Visibility

It's visible from rendered and material preview modes, as long as you use "Scene World"

Visibility



Alternative 2: Plane + Texture (solid mode)

Solid mode settings

For this to work in solid mode, we need to add a regular plane (mesh) and:

  • Keep the plane at 0,0,0, facing up (you can scale in edit mode)
  • Set its texture via nodes (without the BSDF node)
  • Enable the "texture" visualization of the solid mode:

Solid mode options

Dedicated camera

Then we will need to add a new camera, which we will call "ModelCamera". We will use this camera just for modeling. Set the local camera for the view:

View camera

This camera will allow us to keep the main camera unchanged

Camera navigation

Then we need to enter the camera view and allow navigation with zooming (click the padlock)

Camera navigation

This will allow you to move around the same way you would normally, and the camera will follow. (Uncheck the padlock to make the camera frame bigger or smaller, check again to navigate)

Geonodes for plane

Then add these geonodes on the plane (remember the plane should be kept in 0,0,0, facing up to Z, you can scale the plane in "edit mode")

Note there is a "distance" input that controls how far from the camera you want the plane to be.

"Object info" here uses "Model Camera"

Nodes for plane

Result

You can move the position of the image by moving the plane in edit mode on X and Y

Result for solid mode

$\endgroup$
0
0
$\begingroup$

In addition to @unwave's answer, you can keep the Empty consistently locked to a specific position in the 3D Viewport by calculating the viewport's coordinates and updating the Empty’s location accordingly. This approach also allows you to move the Empty manually, and it will remain locked to its new screen-space position.

Here's a script that affects all empties whose names start with Empty_Reference:

import bpy import bpy_extras from mathutils import Vector depth = 10.0 empty_prefix = "Empty_Reference" class VIEW3D_OT_follow_viewport(bpy.types.Operator): bl_idname = "view3d.follow_viewport" bl_label = "Track Viewport Position" bl_options = {'REGISTER'} _timer = None _empties = {} # {object_name: last_location} _screen_positions = {} # {object_name: screen_pos} _suspend_frames = 0 def execute(self, context): self._empties = {obj.name: obj.location.copy() for obj in bpy.data.objects if obj.type == 'EMPTY' and obj.name.startswith(empty_prefix)} ctx = self.find_viewport_context(context) if not self._empties or not ctx: return {'CANCELLED'} region = ctx["region"] region_3d = ctx["region_3d"] region_3d.update() for name in self._empties: obj = bpy.data.objects.get(name) if obj: screen_pos = bpy_extras.view3d_utils.location_3d_to_region_2d(region, region_3d, obj.location) if screen_pos: self._screen_positions[name] = screen_pos wm = context.window_manager self._timer = wm.event_timer_add(0.01, window=context.window) wm.modal_handler_add(self) return {'RUNNING_MODAL'} def modal(self, context, event): if event.type == 'ESC': self.cancel(context) return {'CANCELLED'} if event.type == 'TIMER': ctx = self.find_viewport_context(context) if not ctx: return {'PASS_THROUGH'} region = ctx["region"] region_3d = ctx["region_3d"] region_3d.update() to_remove = [] for name in list(self._empties.keys()): obj = bpy.data.objects.get(name) if obj is None: to_remove.append(name) continue if obj.location != self._empties[name]: self._suspend_frames = 10 self._empties[name] = obj.location.copy() screen_pos = bpy_extras.view3d_utils.location_3d_to_region_2d(region, region_3d, obj.location) if screen_pos: self._screen_positions[name] = screen_pos for name in to_remove: self._empties.pop(name, None) self._screen_positions.pop(name, None) if self._suspend_frames > 0: self._suspend_frames -= 1 return {'PASS_THROUGH'} for name, screen_pos in list(self._screen_positions.items()): obj = bpy.data.objects.get(name) if obj is None: continue coord_2d = (screen_pos.x, screen_pos.y) view_vector = bpy_extras.view3d_utils.region_2d_to_vector_3d(region, region_3d, coord_2d) ray_origin = bpy_extras.view3d_utils.region_2d_to_origin_3d(region, region_3d, coord_2d) location_3d = ray_origin + view_vector * depth obj.location = location_3d self._empties[name] = obj.location.copy() return {'PASS_THROUGH'} def cancel(self, context): wm = context.window_manager wm.event_timer_remove(self._timer) def find_viewport_context(self, context): for window in context.window_manager.windows: screen = window.screen for area in screen.areas: if area.type == 'VIEW_3D': for region in area.regions: if region.type == 'WINDOW': space = area.spaces.active region_3d = space.region_3d return {"window": window, "screen": screen, "area": area, "region": region, "space": space, "region_3d": region_3d} return None def register(): bpy.utils.register_class(VIEW3D_OT_follow_viewport) def unregister(): bpy.utils.unregister_class(VIEW3D_OT_follow_viewport) if __name__ == "__main__": register() bpy.ops.view3d.follow_viewport() 
$\endgroup$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.