因为市面上没有一个轻量化的,轻便的 QvPen 平面板书方案,所以自己做了一个,是一个很简单的工作,想分享给大家。
关键词:QvPen;平面板书;Black Board;Chalk Board
代码内容
需要提一嘴,这个脚本为尽可能节省运算量,将计算参考平面相关的环节放进了start中,因此不支持动态的参考平面,当然这个功能简单改一改就能支持力。
/*
============================================================
项目:VRChat QvPen 强制平面吸附脚本
功能:
将 QvPen 笔尖强制投影吸附至目标平面
约束笔尖活动范围在平面边界内
监听笔的拾取 / 放下状态,联动控制指定对象开关
优化点:
缓存静止数据(变换矩阵、平面方程、边界值),减少每帧重复计算
使用 MultiplyPoint3x4 替代内置变换函数,降低性能开销
模块化结构设计,便于边界问题调试
【新增】仅在拾取状态下进行物理计算,释放空闲时的性能
============================================================
*/
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
[DefaultExecutionOrder(5)]
[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
public class QvPenForcePlane : UdonSharpBehaviour
{
#region 配置字段 (Inspector 中设置)
[Header("核心设置")]
[Tooltip("目标吸附平面 (需是 Unity Plane 或自定义平面 Mesh)")]
public Transform targetPlane;
[Header("平面 Mesh 尺寸")]
[Tooltip("平面 Mesh 在自身局部坐标系下的半长。Unity 原生 Plane 固定为 (5, 5)")]
public Vector2 planeMeshLocalHalfSize = new Vector2(5f, 5f);
[Header("拾取联动")]
[Tooltip("拿起笔时开启、放下时关闭的对象")]
public GameObject objectToToggleOnPickup;
#endregion
#region 私有变量 (运行时缓存)
// QvPen 相关引用
private Transform _inkPosition; // QvPen 原始逻辑位置
private Transform _inkPositionChild; // QvPen 最终绘制位置 (我们修改的对象)
private bool _isInitialized = false; // 初始化完成标志
// 平面与变换缓存 (性能优化)
private Vector3 _cachedPlaneNormal; // 平面法线 (世界空间)
private float _cachedPlaneD; // 平面方程常数项 (ax + by + cz + d = 0)
private Quaternion _cachedTipRotation; // 笔尖固定旋转 (垂直于平面)
private Vector2 _cachedLocalBounds; // 最终边界
private Matrix4x4 _cachedWorldToLocal; // 世界 -> 平面局部矩阵
private Matrix4x4 _cachedLocalToWorld; // 平面局部 -> 世界矩阵
// 拾取状态缓存
private bool _wasObjectToggled = false; // 记录联动对象是否已开启
// [新增] 核心控制开关:是否正在被持有
private bool _isHeld = false;
#endregion
#region 初始化 (Start)
void Start()
{
// 1. 查找 QvPen 必要的层级结构
if (!FindQvPenHierarchy()) return;
// 2. 检查核心引用是否有效
if (targetPlane == null)
{
Debug.LogError("[QvPenForcePlane] 未赋值 Target Plane!", this);
return;
}
// 3. 预计算所有静止数据
CacheStaticData();
// 4. 初始化联动对象状态
if (objectToToggleOnPickup != null)
objectToToggleOnPickup.SetActive(false);
_isInitialized = true;
}
/// <summary>
/// 查找 QvPen 的 InkPosition 层级
/// </summary>
private bool FindQvPenHierarchy()
{
_inkPosition = transform.Find("InkPosition");
if (_inkPosition == null)
{
Debug.LogError("[QvPenForcePlane] 未找到 InkPosition 子对象!", this);
return false;
}
_inkPositionChild = _inkPosition.Find("InkPositionChild");
if (_inkPositionChild == null)
{
Debug.LogError("[QvPenForcePlane] 未找到 InkPositionChild 子对象!", this);
return false;
}
return true;
}
/// <summary>
/// 预计算平面方程、矩阵、边界等静止数据
/// </summary>
private void CacheStaticData()
{
// 1. 缓存平面基本信息
_cachedPlaneNormal = targetPlane.up;
Vector3 planePoint = targetPlane.position;
// 平面方程优化:d = - (normal · point)
_cachedPlaneD = -Vector3.Dot(_cachedPlaneNormal, planePoint);
// 2. 缓存笔尖旋转 (始终垂直于平面)
_cachedTipRotation = Quaternion.LookRotation(-_cachedPlaneNormal, targetPlane.right);
// 3. 计算最终边界 (局部空间)
CalculateLocalBounds();
// 4. 缓存变换矩阵 (避免每帧调用 InverseTransformPoint)
_cachedWorldToLocal = targetPlane.worldToLocalMatrix;
_cachedLocalToWorld = targetPlane.localToWorldMatrix;
}
/// <summary>
/// 计算局部空间下的最终边界 (核心逻辑,待调试)
/// </summary>
private void CalculateLocalBounds()
{
// 优化:既然是独立物体,直接用 localScale,比 lossyScale 更快更准
Vector3 localScale = targetPlane.localScale;
// 最终边界 = Mesh 原始半长 (5) - 局部 Margin
_cachedLocalBounds.x = Mathf.Max(planeMeshLocalHalfSize.x, 0f);
_cachedLocalBounds.y = Mathf.Max(planeMeshLocalHalfSize.y, 0f);
}
#endregion
#region 每帧更新 (LateUpdate)
void LateUpdate()
{
// [修改] 如果没初始化 OR 没被拿在手里,直接返回,不进行任何运算
if (!_isInitialized || !_isHeld) return;
// 1. 核心吸附逻辑
ExecutePlaneSnap();
}
/// <summary>
/// 核心平面吸附与边界约束逻辑
/// </summary>
private void ExecutePlaneSnap()
{
// 步骤 1:读取 QvPen 原始逻辑位置 (不读取被我们修改过的 Child)
Vector3 rawTipPos = _inkPosition.position;
// 步骤 2:将点投影到平面上 (使用缓存的平面方程)
float distanceToPlane = Vector3.Dot(_cachedPlaneNormal, rawTipPos) + _cachedPlaneD;
Vector3 projectedPos = rawTipPos - distanceToPlane * _cachedPlaneNormal;
// 步骤 3:转换到平面局部空间进行边界约束
Vector3 localPos = _cachedWorldToLocal.MultiplyPoint3x4(projectedPos);
// 约束 X/Z 轴 (Y 轴在平面上恒为 0)
localPos.x = Mathf.Clamp(localPos.x, -_cachedLocalBounds.x, _cachedLocalBounds.x);
localPos.z = Mathf.Clamp(localPos.z, -_cachedLocalBounds.y, _cachedLocalBounds.y);
localPos.y = 0f;
// 步骤 4:转换回世界空间
Vector3 finalPos = _cachedLocalToWorld.MultiplyPoint3x4(localPos);
// 步骤 5:应用最终结果
_inkPositionChild.position = finalPos;
_inkPositionChild.rotation = _cachedTipRotation;
}
#endregion
#region VRChat 拾取事件
public override void OnPickup()
{
// [新增] 标记:笔被拿起了,开始每帧运算
_isHeld = true;
if (objectToToggleOnPickup != null && !_wasObjectToggled)
{
objectToToggleOnPickup.SetActive(true);
_wasObjectToggled = true;
}
}
public override void OnDrop()
{
// [新增] 标记:笔被放下了,停止每帧运算
_isHeld = false;
if (objectToToggleOnPickup != null && _wasObjectToggled)
{
objectToToggleOnPickup.SetActive(false);
_wasObjectToggled = false;
}
}
#endregion
#region 调试辅助 (Gizmos 绘制边界)
private void OnDrawGizmosSelected()
{
if (targetPlane == null) return;
// 定义颜色:品红色代表“理论边界”
Gizmos.color = Color.magenta;
// 将 Gizmos 坐标系与目标平面对齐
Gizmos.matrix = targetPlane.localToWorldMatrix;
// --- 核心调试:直接画出你认为的边界 ---
// 注意:这里我们直接用 planeMeshLocalHalfSize (5, 5) 来画,不减去 Margin
// 目的是看看代码认为的“Mesh边缘”在哪里
Vector3 p1 = new Vector3(-planeMeshLocalHalfSize.x, 0, -planeMeshLocalHalfSize.y);
Vector3 p2 = new Vector3(planeMeshLocalHalfSize.x, 0, -planeMeshLocalHalfSize.y);
Vector3 p3 = new Vector3(planeMeshLocalHalfSize.x, 0, planeMeshLocalHalfSize.y);
Vector3 p4 = new Vector3(-planeMeshLocalHalfSize.x, 0, planeMeshLocalHalfSize.y);
Gizmos.DrawLine(p1, p2);
Gizmos.DrawLine(p2, p3);
Gizmos.DrawLine(p3, p4);
Gizmos.DrawLine(p4, p1);
// 画个十字中心点
Gizmos.DrawLine(new Vector3(-planeMeshLocalHalfSize.x, 0, 0), new Vector3(planeMeshLocalHalfSize.x, 0, 0));
Gizmos.DrawLine(new Vector3(0, 0, -planeMeshLocalHalfSize.y), new Vector3(0, 0, planeMeshLocalHalfSize.y));
}
#endregion
}
Log 2026/4/21:发布内容
Log 2026/4/23:发现边界计算相关的错误,对代码进行了一些修改,规范化/模块化格式以提高可读性,优化了性能
Log 2026/4/24:想起来得在不 pickup 的时候做 return,是一处性能优化
使用方式
创建一个U#脚本,复制粘贴代码(不要忘了命名一致)
将脚本挂载到 QvPen 的 Pen Manager 预制件下的 Pen 对象上。

创建一个 Plane对象(用作平面参考),删掉它的 mesh renderer 和 mesh collider,放在场景当中合适的位置。
在 QvPen 脚本下的 Plane Target 槽中放置我们新建的的对应 Plane 对象。
跑就完了。
后记
如果大家有任何的改进建议,请随时在本帖下回复,感谢大家~