UE5的线程同步机制

UE5的线程同步机制

线程的等待一般有3种实现方式,一是轮询(poll):

c++ 复制代码
while(!CheckCondition())
{
    ...
}
...

二是阻塞(block),线程将会进入睡眠状态,当条件满足时,内核再去唤醒线程,系统中有若干函数都可以起到block线程的作用:

  • 打开文件,如fopen函数;
  • 主动睡眠,如pthread_sleep函数;
  • 等待其他线程结束,如pthread_join函数;
  • 等待互斥锁,如pthread_mutex_wait函数。

三是让渡(yield),与轮询类似,只是每次循环会选择让出线程剩余的时间片,而不是忙等待:

c++ 复制代码
while(!CheckCondition())
{
    ...
    pthread_yield(nullptr);
}
...

基于以上,我们来讨论一下UE5中形形色色的线程同步机制。

Mutex

系统库提供的mutex实现是比较耗的,如pthread_mutex_t,STL中的std::mutex,在大多数操作系统中,互斥锁可以在进程之间共享。因此,它是由内核在内部管理的数据结构。这意味着对互斥锁执行的所有操作都涉及内核调用,因此需要在 CPU 上进行上下文切换到受保护模式。即使没有其他线程在争夺锁,互斥锁也相对比较昂贵。所以UE并没有使用系统库来实现mutex,而是使用无锁的方式进行实现。"无锁"是指在等待资源变得可用时防止线程进入睡眠状态的做法。换句话说,在无锁编程中,线程永远也不会阻塞。

Critical Section

有部分操作系统,提供了临界区这个概念。临界区是一个轻量级的锁机制,比Mutex开销要低,UE5提供了两种类型的临界区,FCriticalSectionFSystemWideCriticalSection,分别表示用户模式下的临界区和系统范围的临界区,两者从设计上的区别如下:

类型 优点 缺点
FCriticalSection 效率高,不需要上下文切换 不能用于进程间同步,只能进程内线程间同步
FSystemWideCriticalSection 既可用于进程间同步,也可用于进程内线程间同步 效率低,需要上下文切换

以Windows平台为例,FCriticalSection是利用Windows自带的CRITICAL_SECTION实现的,比较简单:

c++ 复制代码
/**
 * This is the Windows version of a critical section. It uses an aggregate
 * CRITICAL_SECTION to implement its locking.
 */
class FWindowsCriticalSection
{
public:
	FWindowsCriticalSection(const FWindowsCriticalSection&) = delete;
	FWindowsCriticalSection& operator=(const FWindowsCriticalSection&) = delete;

	FORCEINLINE FWindowsCriticalSection()
	{
		Windows::InitializeCriticalSection(&CriticalSection);
		Windows::SetCriticalSectionSpinCount(&CriticalSection,4000);
	}

	FORCEINLINE ~FWindowsCriticalSection()
	{
		Windows::DeleteCriticalSection(&CriticalSection);
	}

	FORCEINLINE void Lock()
	{
		Windows::EnterCriticalSection(&CriticalSection);
	}

	FORCEINLINE bool TryLock()
	{
		if (Windows::TryEnterCriticalSection(&CriticalSection))
		{
			return true;
		}
		return false;
	}

	FORCEINLINE void Unlock()
	{
		Windows::LeaveCriticalSection(&CriticalSection);
	}

private:
	Windows::CRITICAL_SECTION CriticalSection;
};

这里拷贝构造和赋值操作都设置为了delete,并且SetCriticalSectionSpinCount传入了4000,这表示线程在进入等待之前先自旋4000次,如果在自旋途中可以进入临界区,就无需要进入等待状态了。

FSystemWideCriticalSection内部实现使用的是mutex,类的实现遵守RAII,在构造时创建mutex并且持有,只有持有成功mutex才有效;在析构时检查mutex,如果有效释放并销毁它。

c++ 复制代码
FWindowsSystemWideCriticalSection::FWindowsSystemWideCriticalSection(const FString& InName, FTimespan InTimeout)
{
	// Attempt to create and take ownership of a named mutex
	Mutex = CreateMutex(NULL, true, MutexName);

	if (Mutex != NULL && GetLastError() == ERROR_ALREADY_EXISTS)
	{
		// CreateMutex returned a valid handle but we didn't get ownership because another process/thread has already created it
		bool bMutexOwned = false;

		if (InTimeout != FTimespan::Zero())
		{
			// We have a handle already so try waiting for it to be released by the current owner
			DWORD WaitResult = WaitForSingleObject(Mutex, FMath::TruncToInt((float)InTimeout.GetTotalMilliseconds()));

			// WAIT_OBJECT_0 = we got ownership when the previous owner released it
			// WAIT_ABANDONED = we got ownership when the previous owner exited WITHOUT releasing the mutex gracefully (we own it now but the state of any shared resource could be corrupted!)
			if (WaitResult == WAIT_ABANDONED || WaitResult == WAIT_OBJECT_0)
			{
				bMutexOwned = true;
			}
		}

		if (!bMutexOwned)
		{
			// We failed to gain ownership by waiting so close the handle to avoid leaking it.
			CloseHandle(Mutex);
			Mutex = NULL;
		}
	}
}

FWindowsSystemWideCriticalSection::~FWindowsSystemWideCriticalSection()
{
	Release();
}

bool FWindowsSystemWideCriticalSection::IsValid() const
{
	return Mutex != NULL;
}

void FWindowsSystemWideCriticalSection::Release()
{
	if (IsValid())
	{
		// Release ownership
		ReleaseMutex(Mutex);
		// Also release the handle so it isn't leaked
		CloseHandle(Mutex);
		Mutex = NULL;
	}
}

如果已经存在同名的mutex,CreateMutex会返回该mutex,同时GetLastError返回的是ERROR_ALREADY_EXISTS。如果在指定时间内未能持有,就直接放弃。

Semaphore

信号量和互斥锁比较类似,不同之处在于,其一信号量可以计数,也就是说它允许多个线程同时持有同一个信号量,只要信号量的值不低于0;其二,互斥锁的释放只能由获取的那个线程执行,而二值信号量(初始值为1的信号量),它可以被任何线程所signal。Windows平台使用CreateSemaphore创建信号量,WaitForSingleObject获取信号量,ReleaseSemaphore释放信号量:

c++ 复制代码
class FWindowsSemaphore
{
public:
	UE_NONCOPYABLE(FWindowsSemaphore);

	FWindowsSemaphore(int32 InitialCount, int32 MaxCount)
		: Semaphore(CreateSemaphore(nullptr, InitialCount, MaxCount, nullptr))
	{
		checkfSlow(Semaphore, TEXT("CreateSemaphore failed: %u"), GetLastError());
	}

	~FWindowsSemaphore()
	{
		CloseHandle(Semaphore);
	}

	void Acquire()
	{
		DWORD Res = WaitForSingleObject(Semaphore, INFINITE);
		checkfSlow(Res == WAIT_OBJECT_0, TEXT("Acquiring semaphore failed: %d (%u)"), Res, GetLastError());
	}

	bool TryAcquire(FTimespan Timeout = FTimespan::Zero())
	{
		DWORD Res = WaitForSingleObject(Semaphore, (DWORD)Timeout.GetTotalMilliseconds());
		checkfSlow(Res == WAIT_OBJECT_0 || Res == WAIT_TIMEOUT, TEXT("Acquiring semaphore failed: %d (%u)"), Res, GetLastError());
		return Res == WAIT_OBJECT_0;
	}

	void Release(int32 Count = 1)
	{
		checkfSlow(Count > 0, TEXT("Releasing semaphore with Count = %d, that should be greater than 0"), Count);
		bool bRes = ReleaseSemaphore(Semaphore, Count, nullptr);
		checkfSlow(bRes, TEXT("Releasing semaphore for %d failed: %u"), Count, GetLastError());
	}

private:
	HANDLE Semaphore;
};

UE_NONCOPYABLE是一个宏,表示该类的左值/右值拷贝构造函数不被定义,左值/右值赋值操作不被定义。通过这个宏可以禁止同类型的拷贝和赋值。

Memory Order

看下面的代码:

c++ 复制代码
int32_t g_data = 0;
int32_t g_ready = 0;
void ProducerThread()
{
    // produce some data
    g_data = 42;
    // inform the consumer
    g_ready = 1;
}
void ConsumerThread()
{
    // wait for the data to be ready
    while (!g_ready)
    	PAUSE();
    // consume the data
    ASSERT(g_data == 42);
}

CPU可能会对指令进行重新排序,导致排序的结果和代码的顺序不一致。也就是说,在ProducerThread线程中,g_ready的赋值操作可能在g_data之前,而在ConsumerThread线程中,g_data的读取操作也可能发生在g_ready之前,从而导致意想不到的结果。

step Producer Consumer
1 写入g_ready
2 读取g_data
3 读取g_ready
4 写入g_data

除了CPU指令重排外,内存的操作顺序可能也和代码不一致。比如在ProducerThread线程中,g_ready可能要比g_data更早写入cache,导致ConsumerThread线程先看到了g_ready的最新值,再看到g_ready的最新值。内存顺序不一致的情况可以分为如下4种:

  1. 读操作越过了读操作
  2. 读操作越过了写操作
  3. 写操作越过了读操作
  4. 写操作越过了写操作

针对以上每一种情况,都可以提出3种内存屏障策略,单向,反向以及双向。这里的方向指的是代码顺序,单向表示内存屏障前的内存操作必须要在屏障前完成;反向表示内存屏障后的内存操作必须要在屏障后完成,双向就是单向+反向都要满足。那么严格来说就有4*3=12种内存屏障,但实际使用中并不需要那么多,C++提供了如下6种内存屏障:

memory_order_relaxed

没有顺序一致性的要求,也没有内存同步的要求,唯一能保证的就是操作的原子性。

c++ 复制代码
std::atomic<bool> x = false;
std::atomic<bool> y = false;
int z = 0;
// thread A
void write_x_then_y() {
    x.store(true, std::memory_order_relaxed);   // 1
    y.store(true, std::memory_order_relaxed);   // 2
}

// thread B
void read_y_then_x() {
    while (!y.load(std::memory_order_relaxed)) {    // 3
    }
    if (x.load(std::memory_order_relaxed)) {    // 4
        ++z;
    }
}

如上述代码,由于内存序为relaxed,那么A线程中y的写入可能发生在x之前,相应地B线程中x的读取也可能发生在y之前。所以B线程中,就算y的结果为true,x的结果也可能还是false,也就导致z的值为0。

step thread A thread B
1 store y
2 load x
3 load y
4 store x

memory_order_release

memory_order_release保证本线程中,所有之前的读写操作完成后才能执行本条原子操作。memory_order_release通常用于写操作:

c++ 复制代码
std::atomic<bool> x = false;
std::atomic<bool> y = false;
int z = 0;
// thread A
void write_x_then_y() {
    x.store(true, std::memory_order_relaxed);   // 1
    y.store(true, std::memory_order_release);   // 2
}

// thread B
void read_y_then_x() {
    while (!y.load(std::memory_order_relaxed)) {    // 3
    }
    if (x.load(std::memory_order_relaxed)) {    // 4
        ++z;
    }
}
step thread A thread B
1 load x
2 store x
3 store y
4 load y

上述代码中线程A的y使用了memory_order_release,会使得x的store一定发生在y的store之前,也就是说对于线程A,y为true时x一定为true,但这个事情放到线程B上就不一定成立了,线程B依旧可能先读x再读y,导致z的值为0。

memory_order_acquire

与release配对的memory_order_acquire,它保证所有之后的读写操作都要在本条原子操作执行之后才能完成。memory_order_acquire通常用于读操作,如果有其他线程使用memory_order_release对相同原子变量写入,那么memory_order_acquire保证一定能读取到其他线程所有写入的最新值。

c++ 复制代码
std::atomic<bool> x = false;
std::atomic<bool> y = false;
int z = 0;
// thread A
void write_x_then_y() {
    x.store(true, std::memory_order_relaxed);   // 1
    y.store(true, std::memory_order_release);   // 2
}

// thread B
void read_y_then_x() {
    while (!y.load(std::memory_order_acquire)) {    // 3
    }
    if (x.load(std::memory_order_relaxed)) {    // 4
        ++z;
    }
}

上述代码中线程B的y使用了memory_order_acquire,会使得x的load一定发生在y的load之后,并且y在load到最新值时,也会刷新x的最新值。配合线程A的逻辑,y最新值为true时x最新值一定也为true,那么当线程B读到y为true时,说明此时的顺序为store x => store y => load y => load x,x的值必定为true(x的cache在读取y最新值的时候已经被刷新),此时z的值一定为1。

step thread A thread B
1 store x
2 store y
3 load y
4 load x

memory_order_consume

与memory_order_acquire类似,memory_order_consume也通常用于读操作,但是它只保证有依赖的读写操作在本条原子操作执行之后才能完成。而且,只有依赖的变量能保证读取到的是最新值。什么是依赖呢?来看下面这个例子:

c++ 复制代码
int a = 0;
std::atomic<std::string*> ptr;

void Write() {
  std::string* p = new std::string("Hello World.");
  a = 30;
  ptr.store(p, std::memory_order_release);
}

void Read() {
  std::string* p2;
  while (!(p2 = ptr.load(std::memory_order_consume)));

  assert(*p2 == "Hello World.");
  delete p2;
  assert(a == 30);
}

首先来看Write线程,ptr使用了memory_order_release内存序,那么p和a的写入必然在ptr写入之前;再看Read线程,ptr使用了memory_order_consume内存序,那么只有和ptr有关联的变量读写,才会保证发生在ptr读取之后。这里,p2的值取决于ptr,换言之p2依赖于ptr,所以p2的读写一定发生在ptr读取之后,而且在读取到最新的ptr时,p2的最新值也会随之刷新。而a的值与ptr无关,那么memory_order_consume内存序并不能保证a的读取一定发生在ptr之后,也不能保证a读取到的值是最新值(可能还是cache的旧值),从而可能导致a == 30断言失败。

memory_order_acq_rel

memory_order_acquire+memory_order_release的合体,如果原子操作是读操作,那么使用memory_order_acquire内存序,如果原子操作是写操作,那么使用memory_order_release内存序,如果原子操作是read-modify-write这种既有读又有写的,那么acquire和release内存序同时生效。

memory_order_seq_cst

memory_order_acq_rel内存序的加强版,它提供了更加严格的顺序保证,保证所有的线程都能看到最新的数据,这是其他内存序所不具备的,例如下面这个例子:

c++ 复制代码
std::atomic<bool> x = {false};
std::atomic<bool> y = {false};
std::atomic<int> z = {0};

// thread A
void write_x()
{
    x.store(true, std::memory_order_seq_cst);
}

// thread B
void write_y()
{
    y.store(true, std::memory_order_seq_cst);
}

// thread C
void read_x_then_y()
{
    while (!x.load(std::memory_order_seq_cst))
        ;
    if (y.load(std::memory_order_seq_cst))
        ++z;
}

// thread D
void read_y_then_x()
{
    while (!y.load(std::memory_order_seq_cst))
        ;
    if (x.load(std::memory_order_seq_cst))
        ++z;
}

如果将上述代码中的memory_order_seq_cst都替换为memory_order_acq_rel,z的值依旧可能为0。由于x和y的写入在两个线程中进行,所以它们不存在绝对的有序关系。这意味着,对于线程C,虽然memory_order_acq_rel保证x一定在y之前读取到最新的值,但它不能保证x读取到最新值时,y也能读取到最新值,也就是存在x为true而y为false的可能;对于线程D,则不能保证y读取到最新值时,x也能读取到最新值,即有可能出现y为true而x为false的情况。这就是这两件事可以同时发生的原因。

而memory_order_seq_cst可以保证全局的唯一有序性,这意味着,对于线程C和D,它们读取到的x和y一定都是最新的值,也就是说从全局来看,线程A对x的写入要么发生在线程B对y的写入之前,要么之后。那么线程C和线程D必定有一个可以通过while和if的逻辑,z的值一定不会为0。

Spin Lock

UE中实现自旋锁就使用上述提到的内存序,避免全部使用memory_order_seq_cst可以提高程序运行的性能。

c++ 复制代码
class FSpinLock
{
public:
    UE_NONCOPYABLE(FSpinLock);

    FSpinLock() = default;

    void Lock()
    {
        while (true)
        {
            if (!bFlag.exchange(true, std::memory_order_acquire))
            {
                break;
            }

            while (bFlag.load(std::memory_order_relaxed))
            {
                FPlatformProcess::Yield();
            }
        }
    }

    bool TryLock()
    {
        return !bFlag.exchange(true, std::memory_order_acquire);
    }

    void Unlock()
    {
        bFlag.store(false, std::memory_order_release);
    }

private:
    std::atomic<bool> bFlag{ false };
}

exchange是一个read-modify-write操作,在Lock中使用memory_order_acquire可以保证,exchange一定发生在load之前,并且能同步到Unlock中使用memory_order_release的最新值。exchange如果返回false,说明当前线程成功获取到锁,否则就得进入到后续的while循环一直yield。这里Lock用的是两层while,是为了避免每次循环都动用memory_order_acquire,如果第一次判断拿不到锁,后面就使用memory_order_relaxed听天由命,虽然yield的次数潜在增加了,但是同步的代价减少了。

Scoped Lock

主要是防止lock之后忘记unlock而设计的,其实就是RAII的思想,借助C++局部变量离开作用域就会自动析构的逻辑实现的。

c++ 复制代码
template<typename MutexType>
class TScopeLock
{
public:
    UE_NONCOPYABLE(TScopeLock);

    UE_NODISCARD_CTOR TScopeLock(MutexType& InMutex)
        : Mutex(&InMutex)
    {
        check(Mutex);
        Mutex->Lock();
    }

    ~TScopeLock()
    {
        Unlock();
    }

    void Unlock()
    {
        if (Mutex)
        {
            Mutex->Unlock();
            Mutex = nullptr;
        }
    }

private:
    MutexType* Mutex;
};

Reentrant Lock

可重入的锁,就是支持递归调用的锁。一般的锁不支持递归调用,如果两个函数持有了同一个锁,很可能导致死锁:

c++ 复制代码
FSpinLock g_lock;

void A()
{
    TScopeLock<FSpinLock> lock(g_lock);
    // do some work
}

void B()
{
    TScopeLock<FSpinLock> lock(g_lock);
    A(); // deadlock
}

UE的RecursiveMutex就是可重入的锁,它用到了两个原子变量,一个是uint32的union,包含3个字段,另一个是表示当前持有锁的线程ID。

c++ 复制代码
union FRecursiveMutex::FState
{
	struct
	{
		uint32 bIsLocked : 1;
		uint32 bHasWaitingThreads : 1;
		uint32 RecurseCount : 30;
	};
	uint32 Value = 0;

	constexpr FState() = default;

	constexpr explicit FState(uint32 State)
		: Value(State)
	{
	}

	constexpr bool operator==(const FState& Other) const { return Value == Other.Value; }
};

	std::atomic<uint32> State = 0;
	std::atomic<uint32> ThreadId = 0;

bIsLocked表示锁是否被持有,bHasWaitingThreads表示当前锁是否有其他线程在等待,RecurseCount表示同一线程递归调用的次数。那么Lock函数需要处理3种情况:

锁未被持有

这种最简单,使用无锁原子操作compare_exchange_weak修改原子变量State

c++ 复制代码
FState CurrentState(State.load(std::memory_order_acquire));

// Try to acquire the lock if it was unlocked, even if there are waiting threads.
// Acquiring the lock despite the waiting threads means that this lock is not FIFO and thus not fair.
if (LIKELY(!CurrentState.bIsLocked))
{
    FState NewState = CurrentState;
    NewState.bIsLocked = true;
    if (LIKELY(State.compare_exchange_weak(CurrentState.Value, NewState.Value, std::memory_order_acquire)))
    {
        checkSlow(ThreadId.load(std::memory_order_relaxed) == 0 && CurrentState.RecurseCount == 0);
        ThreadId.store(CurrentThreadId, std::memory_order_relaxed);
        return;
    }
}

注意这里使用了两个memory_order_acquire,保证State一定是先load再compare_exchange_weak,且ThreadId的load和store一定在State内存操作之后。同时,虽然CurrentState读取到的未必是最新的值(也即实际已经被Lock了,但线程还认为是Unlock),但是后续的compare_exchange_weak会保证读取到最新的State值,如果比较下来发现不同,则认为加锁失败,说明当前已经被Lock了。compare_exchange_weak也存在误判,即使两者值相等,也有可能返回false,只不过这里返回false只会认为加锁失败,从而走到后续逻辑,没有太大影响。使用weak而不是strong,可以提高程序运行的性能。如果加锁成功,则把当前线程ID写入到ThreadId中,这里使用memory_order_relaxed的原因也是出于性能考虑,如果其他线程读到的值是旧值,也只是会被认为尝试加锁的线程和持有锁的线程不同而已,也会走到对应的处理逻辑,没有太大影响。

锁被同一线程持有

通过线程ID,就可以实现递归加锁,即把调用次数+1即可,这样就不会死锁了:

C++ 复制代码
// Lock recursively if this is the thread that holds the lock.
FState AddState;
AddState.RecurseCount = 1;
State.fetch_add(AddState.Value, std::memory_order_relaxed);

fetch_add会保证State读取到最新的值。

锁被不同线程持有

这个时候线程会根据bHasWaitingThreads的值,采取不同的等待策略,如果当前有其他线程在等待,则会进入到UE维护的线程队列,直到State发生改变(锁被释放,锁没有其他线程等待)才会被唤醒。如果只有自己在等待,则先尝试自旋一定次数,如果在自旋期间未能成功获取到锁,则修改bHasWaitingThreads的值为true,然后进入到UE维护的线程队列,直到被唤醒。

类似地,Unlock函数也需要对3种情况进行处理:

锁被同一线程持有

说明相同线程中还有其他函数持有锁,那么只需要把调用次数-1即可:

c++ 复制代码
// When locked recursively, decrement the count and return.
FState SubState;
SubState.RecurseCount = 1;
State.fetch_sub(SubState.Value, std::memory_order_relaxed);

fetch_sub同理,也保证State读取到最新的值。

没有其他线程在等待锁

此时需要把ThreadIdState清空,并且要保证清空的顺序一定要是先ThreadIdState,所以这里compare_exchange_strong在值相等时使用的是memory_order_release内存序。使用strong的原因是这里如果比较失败,只可能是此时有别的线程尝试加锁失败,进入了等待队列,需要在Unlock结束时唤醒。

c++ 复制代码
ThreadId.store(0, std::memory_order_relaxed);

// Unlocking with no waiting threads only requires resetting the state.
if (LIKELY(!CurrentState.bHasWaitingThreads))
{
    FState NewState;
    if (LIKELY(State.compare_exchange_strong(CurrentState.Value, NewState.Value, std::memory_order_release, std::memory_order_relaxed)))
    {
        return;
    }
}

有其他线程在等待锁

在Unlock结束时唤醒其中一个线程即可。

Readers-Writer Lock

读写锁分为读锁和写锁,允许多个线程同时持有读锁,或者一个线程持有写锁。Windows平台上,UE使用系统自带的SRWLOCK实现读写锁。

c++ 复制代码
FORCEINLINE void ReadLock()
{
    Windows::AcquireSRWLockShared(&Mutex);
}

FORCEINLINE void WriteLock()
{
    Windows::AcquireSRWLockExclusive(&Mutex);
}

FORCEINLINE bool TryReadLock()
{
    return !!Windows::TryAcquireSRWLockShared(&Mutex);
}

FORCEINLINE bool TryWriteLock()
{
    return !!Windows::TryAcquireSRWLockExclusive(&Mutex);
}

FORCEINLINE void ReadUnlock()
{
    Windows::ReleaseSRWLockShared(&Mutex);
}

FORCEINLINE void WriteUnlock()
{
    Windows::ReleaseSRWLockExclusive(&Mutex);
}

Reference

[1] C/C++ 中内存对齐--结构体

[2] C++ struct结构体内存对齐

[3] C++ struct 位域

[4] Memory model synchronization modes

[5] What do each memory_order mean?

[6] 烧脑的内存序Memory Order

[7] UE4原子操作与无锁编程

[8] C语言宏的特殊用法和几个坑

[9] SetCriticalSectionSpinCount function

[10] Using Mutex Objects

[11] Using Semaphore Objects

[12] std::memory_order

[13] C++11标准原子库内存顺序memory_order_consume与memory_order_acquire的差异示例

[14] How do memory_order_seq_cst and memory_order_acq_rel differ?

[15] std::atomic::exchange

[16] std::memory_order_relaxed example in cppreference.com

[17] Concurrency: Atomic and volatile in C++11 memory model

[18] Can't relaxed atomic fetch_add reorder with later loads on x86, like store can?

[19] Slim Reader/Writer (SRW) Locks

相关推荐
向宇it1 小时前
【从零开始入门unity游戏开发之——C#篇25】C#面向对象动态多态——virtual、override 和 base 关键字、抽象类和抽象方法
java·开发语言·unity·c#·游戏引擎
向宇it2 小时前
【从零开始入门unity游戏开发之——C#篇24】C#面向对象继承——万物之父(object)、装箱和拆箱、sealed 密封类
java·开发语言·unity·c#·游戏引擎
每日出拳老爷子5 小时前
【图形渲染】【Unity Shader】【Nvidia CG】有用的参考资料链接
unity·游戏引擎·图形渲染
YY-nb14 小时前
Unity Apple Vision Pro 开发教程:物体识别跟踪
unity·游戏引擎·apple vision pro
向宇it1 天前
【从零开始入门unity游戏开发之——C#篇23】C#面向对象继承——`as`类型转化和`is`类型检查、向上转型和向下转型、里氏替换原则(LSP)
java·开发语言·unity·c#·游戏引擎·里氏替换原则
Cool-浩1 天前
Unity 开发Apple Vision Pro空间锚点应用Spatial Anchor
unity·游戏引擎·apple vision pro·空间锚点·spatial anchor·visionpro开发
一个程序员(●—●)1 天前
四元数旋转+四元数和向量相乘+音频相关
unity·游戏引擎
我的巨剑能轻松搅动潮汐1 天前
【UE5】pmx导入UE5,套动作。(防止“气球人”现象。
ue5
冒泡P2 天前
【Lua热更新】上篇
开发语言·数据结构·unity·c#·游戏引擎·lua
十画_8242 天前
Unity 6 中的新增功能
unity·游戏引擎